diff --git a/.changelog/v2.61.0.md b/.changelog/v2.61.0.md new file mode 100644 index 0000000000..8be7556048 --- /dev/null +++ b/.changelog/v2.61.0.md @@ -0,0 +1,74 @@ +# Release v2.61.0 + +Released: 2026-09-09 + +## Highlights + +**Chief of Staff and scheduled work** +- Scheduled audits can now be organized by labeled lenses, with shared guidance, actionable disabled states, and live maintenance progress. +- Quota Burn and scheduled maintenance now preserve ordered plans and invoke the same maintenance sequence independently of one another. +- Agent lifecycle and completion handling now share one consistent path across CLI, TUI, read-only, and claim runs. + +**Providers and review operations** +- Code review configuration now selects supported providers, models, and effort pins from the shared catalog, with a larger-model fallback for oversized reviews. +- Provider controls, model metadata, and local backend management now use shared ownership and safer readiness rules. + +**Communication and federation** +- Added Beeper as a third communications source, including a desktop bridge and feature-group controls alongside existing messaging integrations. +- Hosted Eidoverse guest sessions now cap microphone input and re-read live session state before relaying it. + +**Creative workspaces** +- Universe Builder now offers character-sheet casting plus relationship graph views, timeline navigation, gap inspection, and poster export. +- Writers Room editing is more focused with collapsible sections and stabilized saves, while video asset readiness and repair warnings are now unified. + +**Reliability and everyday UI** +- Backup, restore, update, notification, voice-call, and cron paths now report failures and preserve state more consistently. +- Responsive layouts, navigation, disabled controls, and mobile headers received focused fixes across the main workspaces. + +## Changes + +### Chief of Staff, audits, and maintenance + +- Added labeled scheduled-audit lenses and shared run guidance, with catalog-derived audit stages and lane tests. +- Added maintenance sequences that can be launched from scheduled tasks or directly from the Schedule tab, with HTTP-safe identifiers and streamed agent notifications. +- Added ordered maintenance steps to Quota Burn and preserved maintenance plans across refresh failures and disabled-run states. +- Hardened autonomous issue filing with shared labels, substantive-review requirements, and issue-count quota guards. +- Compacted completed agents into a responsive grid and kept claim, read-only, and non-coding runs accurately identified. + +### Agent execution and task lifecycle + +- Centralized agent completion and status-transition handling across CLI and TUI spawners. +- Contained scheduled handler loading and execution failures and prevented unrelated skill templates from entering scheduled-task prompts. +- Exposed safe shared seams for provider controls and aligned launch behavior with the Claude harness. +- Preserved boundary contracts for task intake, write-task updates, and spawn block-and-bail gates. + +### Providers, models, and review + +- Added catalog-backed provider and model selection for code review, including effort pins and safe fallback assignments for oversized reviews. +- Gave local model metadata a shared service owner and unified hosted VideoGen backend predicates and asset readiness. +- Added a feature-group toggle for related instance features and attributed group changes to the user. +- Preserved provider control state across refreshes and kept disabled providers from being selected. + +### Communications, federation, and voice + +- Added Beeper as a desktop-bridge communications source and hardened its integration and feature-group behavior. +- Preserved Telegram notification coverage and aligned unread badges with authoritative server totals. +- Kept voice-call openings pending until TTS delivery, decoded misaligned PCM frames, and capped hosted guest microphone input. + +### Creative and media workspaces + +- Redesigned Universe Builder cast entries as character sheets and added relationship graph, timeline, gaps, and poster-export workflows. +- Added focused Writers Room controls, collapsible sections, and stable save behavior for narrow and mobile layouts. +- Centralized pinned video snapshot resolution and unified video repair labels and readiness warnings, including missing metadata. +- Preserved scene, media-queue, and render-lane accounting through retries, cancellations, and recovery paths. + +### Backup, restore, update, and infrastructure + +- Made backup schedule defaults resolve consistently and clarified exclusion-rule state without promising unsupported file inclusion. +- Bound database restores to previewed scope and valid previews, with locked live restore controls. +- Made self-update launch readiness explicit and preserved exact Git paths in CI test selection. +- Improved PM2 log line buffering, cron validation, and cross-platform send guards. + +## Full Changelog + +**Full Diff**: https://github.com/atomantic/PortOS/compare/v2.60.0...v2.61.0 diff --git a/.claude/skills/portos-file-issue/SKILL.md b/.claude/skills/portos-file-issue/SKILL.md index 42d88d1c4e..bc18e8bdba 100644 --- a/.claude/skills/portos-file-issue/SKILL.md +++ b/.claude/skills/portos-file-issue/SKILL.md @@ -9,12 +9,10 @@ description: PortOS conventions for filing GitHub issues and capturing deferred - **Decide, don't defer — avoid decision-blocked issues (`future` / `needs-input`).** A cluster of open issues that each wait on a human decision is worse than a shipped best-guess the user can iterate on. When you would file a follow-up (or claim one) whose only obstacle is an **undecided design choice** — "option A vs B", "which API shape", "is this in scope" — **make the call yourself**: pick the most reasonable option, state it as *the decision* in the issue body (with a one-line rationale), and file it **ready-to-work**, not parked. The user has explicitly accepted the tradeoff — they would rather iterate on top of a wrong-but-shipped choice than accumulate issues that need decisions. Default to your own recommendation; do not punt the decision back to a human. - **Ready-to-work is the default.** Label an issue for its kind (`bug`, `enhancement`, `code-quality`, `plan`) and write a decision-complete body — the problem, the chosen approach, affected files/paths, and acceptance criteria — so another agent can pick it up cold and ship it. `plan` marks the claimable queue that `/do:next --issues` drains. - - **Dispatch hints and contributor labels are optional and independent.** When the work you just inspected justifies them, also apply: - - `model:light|medium|heavy` — capability (mechanical vs. hard reasoning) - - `effort:low|medium|high|xhigh|max` — reasoning budget per step, independent of `model:` - - `good first issue` — self-contained, well-specified, a new contributor can ship it without deep repo context. A `model:light` 40-file sweep is **not** a good first issue. - - `help wanted` — extra hands welcome and the body is scoped enough to pick up cold. - - **Planner attribution (`planner:`) records who WROTE the plan.** It is a third independent axis — the author, not a recommendation about how to run the work. Take the label verbatim from the "Planner Attribution" section of your own run's prompt (PortOS resolves it from the provider/model it dispatched you with); **never guess it from what you believe you are**, and omit the axis entirely when your run was given none. Create it like any other label: `gh label create planner: --color C2185B --description 'Plan authored by the model' 2>/dev/null || true`. - Omit an axis rather than guessing. Do not stamp `medium` on both dispatch axes. Do not put `[model:…]` / `[effort:…]` / `[category]` / `[SEVERITY]` in the title. Create each missing label immediately before applying it (`gh label create --color 2>/dev/null || true`; colors: model:light `D4C5F9`, model:medium `A371F7`, model:heavy `6F42C1`, effort:low `BFE5E5`, effort:medium `76C7C7`, effort:high `1D7874`, effort:xhigh `0E4F4C`, effort:max `05403D`, good first issue `7057FF`, help wanted `008672`). Use repeated `--label` flags. Never relabel a deduplicated existing issue. + - **Apply the canonical issue-label contract.** Follow the runtime **Issue Filing Labels** section and take planner identity from **Planner Attribution**. For a standalone run without that section, read the shared contract from the repository root: + ```bash + node --input-type=module -e "import { MANDATORY_DISPATCH_HINT_GUIDANCE } from './server/lib/dispatchLabels.js'; console.log(MANDATORY_DISPATCH_HINT_GUIDANCE)" + ``` + This is the source of truth for mandatory dispatch axes, contributor-label eligibility, label creation, and read-back verification. Do not maintain a second vocabulary here. - **`future` is almost never correct.** Do NOT use it to park a choice you could make yourself. The narrow legitimate park is work a **human must personally drive** — a real fidelity/validation run only they can judge, or a step that needs **specific hardware/credentials** the agent doesn't have (e.g. a CUDA box, an Apple-Silicon-only validation). Mark those `blocked` (skipped by autonomous claim runs), not `future`, and say in the body exactly what human action unblocks it. `blocked` also covers a genuine dependency on another unshipped issue/PR — for that case, name the dependency on its own line as `Blocked by #N` (or `Blocked by #N, #M` for more than one): the `issue-reconcile` scheduled task (`blockedIssueReconcile.js`) parses exactly that line, and removes the label automatically once every named issue is closed. An issue with no `Blocked by` line is never auto-unblocked, so the human/hardware case is unaffected. - **`needs-input` is a last resort, not a reflex.** When claiming an ambiguous issue, prefer proceeding on your best interpretation and shipping it. Fall back to `needs-input` only when proceeding would be **destructive/irreversible** or genuinely requires the human (hardware, credentials, a personal judgment call) — never merely because more than one reasonable reading exists. diff --git a/.gitignore b/.gitignore index 4d97b6dc44..ea0aceab58 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Dependencies node_modules/ +/client/node_modules +/server/node_modules # Build output dist/ diff --git a/AGENTS.md b/AGENTS.md index da0e8b6e48..ad210b143d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -119,7 +119,11 @@ Client- and server-specific conventions live in nested memory files that load wh `server/lib/navManifest.js` is the single source of truth for navigation: `NAV_COMMANDS` + `resolveNavCommand()`, consumed by both the `⌘K` palette and the voice agent's `ui_navigate` tool. **Adding a `` without a `NAV_COMMANDS` entry leaves the page unreachable from `⌘K` and un-navigable by voice.** Invoke the `portos-add-page` skill for the entry shape, palette-action wiring, and the fail-fast guards. -**Optional features gate navigation, not routes.** `server/lib/instanceFeatureRegistry.js` declares the optional per-install features (`post`, `datadog`, `jira`) the user toggles in **Settings > Features**; `server/services/instanceFeatures.js` resolves each one as stored override → auto-detection of the integration it fronts → `defaultEnabled`. A nav entry tagged `feature: ''` (or living in a section listed in navManifest's `SECTION_FEATURE` map) drops out of `⌘K` and the sidebar while that feature is off — **the `` keeps working, so bookmarks, direct links, and voice `ui_navigate` still resolve**. The gate is applied CLIENT-side (`useInstanceFeatures` + `client/src/lib/navFeatures.js`), not by filtering the manifest response: `⌘K` and the voice widget each fetch `/api/palette/manifest` once per session and it is HTTP-cached, so a server-side filter would both defeat that cache and still show hidden pages until a reload. Tag the sidebar row in `client/src/components/Layout.jsx` with the same id; `navManifest.test.js` scrapes both lists and fails when they drift, when a tag names an unregistered feature, or when a `SECTION_FEATURE` key stops matching a live section. +**Optional features gate navigation, not routes.** `server/lib/instanceFeatureRegistry.js` declares the optional per-install features (`post`, `datadog`, `jira`) the user toggles in **Settings > Features**; `server/services/instanceFeatures.js` resolves each one as stored override → auto-detection of the integration it fronts → `defaultEnabled`. A nav entry tagged `feature: ''` (or living in a section listed in navManifest's `SECTION_FEATURE` map) drops out of `⌘K` and the sidebar while that feature is off — **the `` keeps working, so bookmarks, direct links, and voice `ui_navigate` still resolve**. The gate is applied CLIENT-side (`useInstanceFeatures` + `client/src/lib/navFeatures.js`), not by filtering the manifest response: `⌘K` and the voice widget each fetch `/api/palette/manifest` once per session and it is HTTP-cached, so a server-side filter would both defeat that cache and still show hidden pages until a reload. A sidebar row still needs its own `NAV_PRESENTATION` entry in `client/src/components/Layout.jsx` (path → icon); `feature`, `section` and `label` are inherited from `NAV_COMMANDS`, so the path is the one thing declared twice — the icon lives only in `NAV_PRESENTATION` — and a manifest tag alone yields no sidebar row — `NAV_PRESENTATION`'s keys are the set the sidebar iterates. `Layout.test.jsx` pins that every Settings, Digital Twin and Messages sub-tab path has a `NAV_PRESENTATION` entry, and that each entry stays presentation-only (an icon, no `to`/`label`/`section`/`feature`) keyed to a live manifest path. `navManifest.test.js` fails when a tag names an unregistered feature, or when a `SECTION_FEATURE` key stops matching a live section. + +**Feature groups bucket related features under one toggle, additively.** `INSTANCE_FEATURE_GROUPS` in `server/lib/instanceFeatureRegistry.js` declares the groups (today: `comms`, holding FaceTime Audio, iMessage, Signal, X, Stacker News and Beeper); a feature joins one by carrying `group: ''` on its descriptor, which is the whole edit. `resolveOne` in `server/services/instanceFeatures.js` resolves a grouped feature as **its own stored override → the group flag → the detector → `defaultEnabled`**: an explicit per-feature override always wins, and only a feature left on "inherit" answers to its group (group off hides it, group on hands it straight back to its normal resolution). Setting an override back to inherit deletes the stored key rather than writing a third sentinel, so it reads exactly like a feature nobody ever touched. **A group's own `enabled` defaults to `true` when no group state is stored** — that default is the parity guarantee: an install with no `instanceFeatureGroups` in settings resolves every member exactly as it did before the group existed, so registering a group is never a silent hide and needs no settings migration. Malformed group settings fail toward `false`, matching the per-feature override's own posture. An ungrouped feature is completely unaffected. + +**A feature toggle that arms background work must reconcile that work at toggle time, not only at boot.** A subsystem gated on "feature on AND credential present" and started once in `services/bootstrap.js` is silently wrong the moment either half of that gate moves at runtime: on a live install, storing a Beeper credential left the realtime transport down and no sweep registered for 48 minutes, until a restart — and the mirror-image gap left a socket relaying on a token a disconnect had just revoked. Give the subsystem one idempotent `reconcile…()` that reads the gate and moves everything to match it, and call it from every path that can move the gate (each credential write, the feature toggle, the group toggle, disconnect). Make repeat calls no-ops rather than re-registrations — re-`schedule()`ing an existing event resets `nextRunAt` a full interval out, so an unrelated toggle would keep pushing the next run away — serialize overlapping calls on one tail, and log transitions only, so an install that has never enabled the feature still narrates nothing. `server/services/beeperArming.js` is the worked example. ### Slashdo Commands (`lib/slashdo`) diff --git a/client/src/App.jsx b/client/src/App.jsx index 6fa6174d2b..e07ad82f9b 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -309,6 +309,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/client/src/components/BackupWidget.jsx b/client/src/components/BackupWidget.jsx index e1447c5f67..f7e6f2288c 100644 --- a/client/src/components/BackupWidget.jsx +++ b/client/src/components/BackupWidget.jsx @@ -1,4 +1,4 @@ -import { useState, memo, useCallback, useId } from 'react'; +import { useState, memo, useCallback, useId, useRef } from 'react'; import { Link } from 'react-router'; import {HardDrive, ChevronDown, @@ -14,6 +14,7 @@ import {HardDrive, import BrailleSpinner from './BrailleSpinner'; import toast from './ui/Toast'; import * as api from '../services/api'; +import { useBackupRun } from '../hooks/useBackupRun'; import { useAutoRefetch } from '../hooks/useAutoRefetch'; import { useTimeTick } from '../hooks/useTimeTick'; import { equalByKeys, equalListByKeys } from '../lib/compareHelpers'; @@ -58,44 +59,73 @@ const HEALTH_STYLES = { // RestorePanel // --------------------------------------------------------------------------- -function RestorePanel({ snapshot, onClose }) { +function RestorePanel({ snapshot, onClose, restoring, onRestoreStateChange }) { const filterId = useId(); const [filter, setFilter] = useState(''); - const [preview, setPreview] = useState(null); + const [acceptedPreview, setAcceptedPreview] = useState(null); const [previewing, setPreviewing] = useState(false); - const [restoring, setRestoring] = useState(false); + const previewGenerationRef = useRef(0); + + const currentRequest = { + snapshotId: snapshot.id, + subdirFilter: filter.trim() || null, + }; + const previewMatchesCurrentRequest = acceptedPreview + && acceptedPreview.request.snapshotId === currentRequest.snapshotId + && acceptedPreview.request.subdirFilter === currentRequest.subdirFilter; + + const handleFilterChange = useCallback((event) => { + previewGenerationRef.current += 1; + setFilter(event.target.value); + setAcceptedPreview(null); + setPreviewing(false); + }, []); const handlePreview = useCallback(async () => { - setPreviewing(true); - setPreview(null); - const result = await api.restoreBackup({ + const generation = previewGenerationRef.current + 1; + previewGenerationRef.current = generation; + const request = { snapshotId: snapshot.id, + subdirFilter: filter.trim() || null, + }; + setPreviewing(true); + setAcceptedPreview(null); + const outcome = await api.restoreBackup({ + ...request, dryRun: true, - subdirFilter: filter.trim() || null - }, { silent: true }).catch(err => { - toast.error(`Preview failed: ${err.message}`); - return null; - }); + }, { silent: true }).then( + previewResult => ({ previewResult }), + previewError => ({ previewError }), + ); + + if (previewGenerationRef.current !== generation) return; setPreviewing(false); - if (result) setPreview(result); + if (outcome.previewError) { + toast.error(`Preview failed: ${outcome.previewError.message}`); + return; + } + if (outcome.previewResult) setAcceptedPreview({ request, result: outcome.previewResult }); }, [snapshot.id, filter]); const handleRestore = useCallback(async () => { - setRestoring(true); + if (!previewMatchesCurrentRequest || restoring) return; + + onRestoreStateChange(snapshot.id); const result = await api.restoreBackup({ - snapshotId: snapshot.id, + ...acceptedPreview.request, dryRun: false, - subdirFilter: filter.trim() || null }, { silent: true }).catch(err => { toast.error(`Restore failed: ${err.message}`); return null; }); - setRestoring(false); + onRestoreStateChange(null); if (result) { toast.success(`Restore complete — ${result.changedFiles?.length ?? 0} file(s) restored`); onClose(); } - }, [snapshot.id, filter, onClose]); + }, [acceptedPreview, onClose, onRestoreStateChange, previewMatchesCurrentRequest, restoring, snapshot.id]); + + const preview = previewMatchesCurrentRequest ? acceptedPreview.result : null; return (
@@ -105,7 +135,8 @@ function RestorePanel({ snapshot, onClose }) { @@ -120,7 +151,8 @@ function RestorePanel({ snapshot, onClose }) { id={filterId} type="text" value={filter} - onChange={e => setFilter(e.target.value)} + onChange={handleFilterChange} + disabled={restoring} placeholder="e.g., brain" className="w-full bg-port-card border border-port-border rounded px-3 py-1.5 text-sm text-white placeholder-gray-600 focus:outline-hidden focus:border-port-accent" /> @@ -132,7 +164,7 @@ function RestorePanel({ snapshot, onClose }) { {/* Preview button */}
@@ -293,8 +327,9 @@ const BackupWidget = memo(function BackupWidget() { ]), }, ); - const [triggering, setTriggering] = useState(false); + const [handleBackupNow, triggering] = useBackupRun(); const [snapshotsOpen, setSnapshotsOpen] = useState(false); + const [restoringSnapshotId, setRestoringSnapshotId] = useState(null); // Tick every minute so the dedup-skipped widget still recomputes // `relativeTime(lastRun/nextRun)` labels and the `computeHealth` 25h/49h // thresholds when wall-clock time crosses a boundary even though the poll @@ -304,18 +339,6 @@ const BackupWidget = memo(function BackupWidget() { const health = computeHealth(status); const { dot, text, icon: HealthIcon } = HEALTH_STYLES[health]; - const handleBackupNow = useCallback(async () => { - setTriggering(true); - await api.triggerBackup({ silent: true }).catch(err => { - toast.error(`Backup failed: ${err.message}`); - }).then(result => { - if (result) { - toast.success('Backup started', { icon: '💾' }); - } - }); - setTriggering(false); - }, []); - const isRunning = status?.status === 'running'; const isNever = status?.status === 'never'; @@ -418,7 +441,8 @@ const BackupWidget = memo(function BackupWidget() { {/* Toggle snapshots */} +

Choose from your enabled AI providers. Providers need an API text transport or an enforced tool-free harness to run reviews.

+ + )}
Reviewers (in order): {selected.length > 0 && ( @@ -690,7 +732,7 @@ export default function ReviewerPicker({ disabled={disabled || index === 0} onClick={() => move(index, -1)} className="text-gray-500 hover:text-white disabled:opacity-30 disabled:hover:text-gray-500" - aria-label={`Move ${reviewerLabel(value)} earlier`} + aria-label={`Move ${labelFor(value)} earlier`} > @@ -699,13 +741,13 @@ export default function ReviewerPicker({ disabled={disabled || index === selected.length - 1} onClick={() => move(index, 1)} className="text-gray-500 hover:text-white disabled:opacity-30 disabled:hover:text-gray-500" - aria-label={`Move ${reviewerLabel(value)} later`} + aria-label={`Move ${labelFor(value)} later`} >
- {reviewerLabel(value)} + {labelFor(value)} {renderUnavailableBadge(value)} Model @@ -714,19 +756,19 @@ export default function ReviewerPicker({
{renderEffortCell(value)}
Optional
- {renderOptToggle(value, reviewerLabel(value), isOptional(value) - ? `${reviewerLabel(value)} is non-blocking (~opt): an inconclusive verdict from it won't block the merge. Click to make it blocking.` - : `${reviewerLabel(value)} gates the merge. Click to make it non-blocking (~opt) — its inconclusive verdicts won't block the merge (a hard failure still does).`)} + {renderOptToggle(value, labelFor(value), isOptional(value) + ? `${labelFor(value)} is non-blocking (~opt): an inconclusive verdict from it won't block the merge. Click to make it blocking.` + : `${labelFor(value)} gates the merge. Click to make it non-blocking (~opt) — its inconclusive verdicts won't block the merge (a hard failure still does).`)}
Max iterations -
{renderMaxRounds(value, reviewerLabel(value))}
+
{renderMaxRounds(value, labelFor(value))}
diff --git a/client/src/components/cos/ReviewerPicker.test.jsx b/client/src/components/cos/ReviewerPicker.test.jsx index 559c918032..c58c6b5e01 100644 --- a/client/src/components/cos/ReviewerPicker.test.jsx +++ b/client/src/components/cos/ReviewerPicker.test.jsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -5,6 +6,26 @@ import ReviewerPicker from './ReviewerPicker'; import { typeSettled } from '../../test/settledInput'; describe('ReviewerPicker', () => { + it('adds, edits, and clears effort for a configured provider reviewer', () => { + const onChange = vi.fn(); + const providers = [{ id: 'codex-tui', name: 'Codex TUI', command: 'codex', enabled: true, defaultModel: 'gpt-6-astra', models: ['gpt-6-astra'] }]; + function Form() { + const [config, setConfig] = useState({ reviewers: [] }); + return { onChange(value); setConfig(value); }} />; + } + render(
); + fireEvent.change(screen.getByLabelText('Provider'), { target: { value: 'codex-tui' } }); + fireEvent.change(screen.getByLabelText('Thinking effort'), { target: { value: 'ultra' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add provider reviewer' })); + const effort = screen.getByLabelText('Reasoning effort for Codex TUI'); + expect(effort).toHaveValue('ultra'); + expect([...effort.options].map(option => option.value)).not.toContain('minimal'); + fireEvent.change(effort, { target: { value: 'high' } }); + expect(onChange.mock.lastCall[0].reviewerEfforts).toEqual({ 'provider:codex-tui': 'high' }); + fireEvent.change(effort, { target: { value: '' } }); + expect(onChange.mock.lastCall[0].reviewerEfforts).toEqual({}); + }); + it('renders the selected reviewers in order with numbered badges', () => { render( {}} />); expect(screen.getByText('1.')).toBeInTheDocument(); diff --git a/client/src/components/cos/StatCard.jsx b/client/src/components/cos/StatCard.jsx index 425a1bf98f..f36734f063 100644 --- a/client/src/components/cos/StatCard.jsx +++ b/client/src/components/cos/StatCard.jsx @@ -41,7 +41,7 @@ const SIZES = { }, }; -export default function StatCard({ label, value, icon, active, activeLabel, compact, mini, tone, onClick, title }) { +export default function StatCard({ label, value, icon, active, activeLabel, compact, mini, tone, onClick, title, className }) { const ariaLabel = `${label}: ${value}${active && activeLabel ? `, ${activeLabel}` : ''}`; // Only tint the icon when a tone was asked for — tone-less callers pass a // pre-colored icon element of their own. @@ -65,7 +65,7 @@ export default function StatCard({ label, value, icon, active, activeLabel, comp const interactiveClass = onClick ? `text-left hover:bg-port-card/60${hasNeutralBorder ? ' hover:border-port-accent-2/50' : ''}` : ''; - const shellClass = `${size.bg} border transition-all ${size.root} ${borderClass} ${interactiveClass}`; + const shellClass = `${size.bg} border transition-all ${size.root} ${borderClass} ${interactiveClass}${className ? ` ${className}` : ''}`; if (compact) { return ( diff --git a/client/src/components/cos/tabs/AgentCard.jsx b/client/src/components/cos/tabs/AgentCard.jsx index fc8841ddbf..69f7601c4b 100644 --- a/client/src/components/cos/tabs/AgentCard.jsx +++ b/client/src/components/cos/tabs/AgentCard.jsx @@ -25,6 +25,7 @@ import { Sparkles, RefreshCw, Copy, + Eye, Target } from 'lucide-react'; import * as api from '../../../services/api'; @@ -46,6 +47,19 @@ import { useConfirmDelete } from '../../../hooks/useConfirmDelete'; const RE_NUMBERED_LIST = /([.!?:]) (\d+)\. /g; const RE_DASH_LIST = /([.!?:]) - /g; const RE_SECTION_LABELS = / (Expected output|Steps|Success criteria|Actionable focus|Focus|Suggestions?|Notes?|Context|Requirements?|Constraints?|Result|Output|Summary|Details)([: ])/gi; +// Every posture the agent-configuration badge row can render. Kept beside the row +// so adding a badge and widening its visibility guard is one edit, not two — a +// badge left out of this list silently never renders. +const CONFIG_BADGE_KEYS = [ + 'configClaimFlow', + 'configUseWorktree', + 'configReadOnly', + 'configCodingOnMain', + 'configOpenPR', + 'configSimplify', + 'configReviewLoop', +]; + const FINISH_SENTINEL_MESSAGE = 'Finish work and write sentinel.'; // Normalize raw task description text into markdown for readable rendering. @@ -172,10 +186,10 @@ function GoalFidelityPanel({ review }) { if (!tone) return null; return (
-
+
@@ -200,8 +214,8 @@ function GoalFidelityPanel({ review }) { ); } -export default function AgentCard({ agent, onPause, onKill, onDelete, onResume, onRelaunch, completed, paused = false, liveOutput, durations, onFeedbackChange, remote, peerName }) { - const [expanded, setExpanded] = useState(false); +export default function AgentCard({ agent, onPause, onKill, onDelete, onResume, onRelaunch, completed, paused = false, liveOutput, durations, onFeedbackChange, remote, peerName, initiallyExpanded = false }) { + const [expanded, setExpanded] = useState(initiallyExpanded); const [now, setNow] = useState(Date.now()); const [fullOutput, setFullOutput] = useState(null); const [loadingOutput, setLoadingOutput] = useState(false); @@ -526,17 +540,17 @@ export default function AgentCard({ agent, onPause, onKill, onDelete, onResume, }, [activeStageTab, pipelineStages, stageOutputs, fetchStageOutput]); return ( -
-
+
{/* Top row: Agent ID, badges, and actions */} -
-
+
+
+ +

{MAINTENANCE_ORDER_GUIDANCE}

+

Resolve findings between audits, then document the resulting code. Run the whole sequence now from here, or schedule it under quota gates in Quota Burn; both drain claim-issue between steps.

+
+ Run maintenance now + +
+
+ {improvementDisabled && (
@@ -206,7 +238,11 @@ export default function ScheduleTab({ apps, providers, providersLoaded, activePr onSelectTask={setSelectedTask} improvementDisabled={improvementDisabled} filter={filter} + label={label} + onLabelChange={setLabel} onFilterChange={setFilter} + sort={sort} + onSortChange={setSort} /> {schedule.lastUpdated && ( @@ -229,6 +265,7 @@ export default function ScheduleTab({ apps, providers, providersLoaded, activePr onUpdateOverride={handleUpdateOverride} onBulkToggleOverride={handleBulkToggleOverride} allTaskTypes={allTaskTypes} + orderStep={selectedOrderStep} improvementDisabled={improvementDisabled} dataInputCatalog={schedule.dataInputCatalog || []} /> diff --git a/client/src/components/cos/tabs/ScheduleTab.test.jsx b/client/src/components/cos/tabs/ScheduleTab.test.jsx index 987935ea60..a9e40d7870 100644 --- a/client/src/components/cos/tabs/ScheduleTab.test.jsx +++ b/client/src/components/cos/tabs/ScheduleTab.test.jsx @@ -8,6 +8,9 @@ const api = vi.hoisted(() => ({ getCodeReviewDefaults: vi.fn(), getCosSchedule: vi.fn(), triggerCosOnDemandTask: vi.fn(), + updateCosTaskInterval: vi.fn(), + getLocalLlmStatus: vi.fn().mockResolvedValue(null), + getProviders: vi.fn().mockResolvedValue({ providers: [] }), })); vi.mock('../../ui/Toast', () => ({ default: toast })); @@ -153,3 +156,43 @@ describe('ScheduleTab on-demand feedback', () => { expect(await screen.findByText(/CoS daemon is stopped/)).toBeVisible(); }); }); + + +describe('Schedule labels', () => { + it('filters by a linked label, searches legacy names, and saves custom labels through the drawer', async () => { + const user = userEvent.setup(); + api.getCodeReviewDefaults.mockResolvedValue({}); + api.getCosSchedule.mockResolvedValue({ tasks: { + security: { type: 'on-demand', enabled: false, displayName: 'better-security', defaultLabels: ['slashdo'], labels: [], description: 'Security audit' }, + 'claim-issue': { type: 'on-demand', enabled: false, description: 'Claim work' }, + } }); + api.updateCosTaskInterval.mockResolvedValue({ success: true, interval: { labels: ['backend'] } }); + render(); + expect(await screen.findByText('better-security')).toBeTruthy(); + expect(screen.queryByText('claim-issue')).toBeNull(); + await user.click(screen.getByRole('button', { name: 'Clear label' })); + expect(screen.getByText('claim-issue')).toBeTruthy(); + await user.type(screen.getByRole('textbox', { name: 'Filter tasks by name' }), 'security'); + expect(screen.queryByText('claim-issue')).toBeNull(); + await user.click(screen.getByText('better-security')); + const labels = await screen.findByRole('textbox', { name: 'Custom labels' }); + await user.type(labels, 'backend'); + await user.tab(); + await waitFor(() => expect(api.updateCosTaskInterval).toHaveBeenCalledWith('security', { labels: ['backend'] }, { silent: true })); + expect(await screen.findByRole('option', { name: 'backend (1)' })).toBeTruthy(); + }); +}); + +it('keeps maintenance selections and the last saved schedule after a refresh failure', async () => { + const user = userEvent.setup(); + api.getCodeReviewDefaults.mockResolvedValue({}); + api.getCosSchedule.mockReset().mockResolvedValueOnce({ tasks: {} }).mockRejectedValueOnce(new Error('offline')); + render(); + await user.click(await screen.findByText('Run maintenance now')); + await user.selectOptions(screen.getByLabelText('App'), 'example'); + await user.click(screen.getByRole('button', { name: 'Refresh' })); + await waitFor(() => expect(api.getCosSchedule).toHaveBeenCalledTimes(2)); + expect(screen.getByLabelText('App')).toHaveValue('example'); + expect(screen.getByRole('button', { name: 'Run now' })).toBeDisabled(); + expect(screen.queryByText('Failed to load task schedule')).not.toBeInTheDocument(); +}); diff --git a/client/src/components/cos/tabs/schedule/AppTaskCard.jsx b/client/src/components/cos/tabs/schedule/AppTaskCard.jsx index 4e1027dfe4..8d6be20225 100644 --- a/client/src/components/cos/tabs/schedule/AppTaskCard.jsx +++ b/client/src/components/cos/tabs/schedule/AppTaskCard.jsx @@ -9,7 +9,7 @@ import TaskModelQuickControls from './TaskModelQuickControls'; // One scheduled task rendered as a status-rich card. Browsing plus the common // "retarget the model and run it" loop happen here; the rest of the // configuration lives in the slide-over drawer (opened via Configure). -export default function AppTaskCard({ taskType, config, apps, onTrigger, onConfigure, onUpdate, providers, providersLoaded = true, activeProviderId, improvementDisabled }) { +export default function AppTaskCard({ taskType, config, apps, onTrigger, onConfigure, onUpdate, providers, providersLoaded = true, activeProviderId, improvementDisabled, orderStep }) { // Owned here, not in the controls, so Run can gate on the same `saving` flag — // it reads the server-side config, so a run fired mid-write uses the old pins. const pins = useTaskModelPins({ taskType, config, providers, activeProviderId, onUpdate }); @@ -27,12 +27,15 @@ export default function AppTaskCard({ taskType, config, apps, onTrigger, onConfi return (
+ {/* `flex flex-col items-stretch` is load-bearing: a stretched
diff --git a/client/src/components/cos/tabs/schedule/AppTaskTypeSection.jsx b/client/src/components/cos/tabs/schedule/AppTaskTypeSection.jsx index ec58e2b597..2f110bc75c 100644 --- a/client/src/components/cos/tabs/schedule/AppTaskTypeSection.jsx +++ b/client/src/components/cos/tabs/schedule/AppTaskTypeSection.jsx @@ -1,31 +1,37 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Search, X } from 'lucide-react'; import AppTaskCard from './AppTaskCard'; -import { TASK_FILTERS, DEFAULT_FILTER_ID, taskSortKey } from './scheduleConstants'; +import { TASK_FILTERS, DEFAULT_FILTER_ID, TASK_SORTS, compareByStatus, compareBySuggestedOrder, suggestedOrderSteps, taskLabels } from './scheduleConstants'; -export default function AppTaskTypeSection({ tasks, apps, providers, providersLoaded, activeProviderId, onTrigger, onUpdate, onSelectTask, improvementDisabled, filter, onFilterChange }) { +export default function AppTaskTypeSection({ tasks, apps, providers, providersLoaded, activeProviderId, onTrigger, onUpdate, onSelectTask, improvementDisabled, filter, onFilterChange, label = '', onLabelChange, sort, onSortChange }) { const [search, setSearch] = useState(''); const taskEntries = Object.entries(tasks || {}); + // Ranked over every task, not just the visible ones, so filtering to one + // label doesn't renumber the sequence under the user. + const orderSteps = useMemo(() => suggestedOrderSteps(tasks), [tasks]); const activeFilter = TASK_FILTERS.find(f => f.id === filter) || TASK_FILTERS[0]; const counts = Object.fromEntries(TASK_FILTERS.map(f => [f.id, taskEntries.filter(f.match).length])); + const labels = [...new Set(taskEntries.flatMap(([, config]) => taskLabels(config)))].sort(); const query = search.trim().toLowerCase(); const visibleEntries = taskEntries .filter(activeFilter.match) - .filter(([taskType]) => !query || taskType.toLowerCase().includes(query)) - .sort(([aType, aConfig], [bType, bConfig]) => { - const a = taskSortKey(aType, aConfig); - const b = taskSortKey(bType, bConfig); - return a.order - b.order || a.next - b.next || a.taskType.localeCompare(b.taskType); - }); + .filter(([, config]) => !label || taskLabels(config).includes(label)) + .filter(([taskType, config]) => !query || [taskType, config.displayName, config.description, ...taskLabels(config)].filter(Boolean).join(' ').toLowerCase().includes(query)) + .sort(sort === 'suggested-order' ? compareBySuggestedOrder(orderSteps) : compareByStatus); if (taskEntries.length === 0) return null; return (
-
-

Improvement Tasks

+
+
+

Improvement Tasks

+

+ Tasks that analyze and improve PortOS and managed apps. Click a card to configure its schedule and to turn it on or off per app. +

+
{TASK_FILTERS.map(f => { const active = activeFilter.id === f.id; @@ -46,35 +52,48 @@ export default function AppTaskTypeSection({ tasks, apps, providers, providersLo })}
-

- Tasks that analyze and improve PortOS and managed apps. Click a card to configure its schedule and to turn it on or off per app. -

- -
- - setSearch(e.target.value)} - placeholder="Filter tasks by name…" - aria-label="Filter tasks by name" - className="w-full bg-port-card border border-port-border rounded-lg pl-9 pr-9 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-port-accent/50" - /> - {search && ( - - )} + {/* Label + search share one row so the card grid starts higher above the fold. */} +
+
+ + setSearch(e.target.value)} + placeholder="Search names, descriptions, labels…" + aria-label="Filter tasks by name" + className="w-full bg-port-card border border-port-border rounded-lg pl-9 pr-9 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:border-port-accent/50" + /> + {search && ( + + )} +
+ + + {label && } + +
{visibleEntries.length === 0 ? (
- {query + {label ? <>No tasks match this label and the current filters. : query ? <>No tasks match “{search.trim()}”. : <>{activeFilter.emptyMessage}{' '} {activeFilter.id !== DEFAULT_FILTER_ID && ( @@ -96,6 +115,7 @@ export default function AppTaskTypeSection({ tasks, apps, providers, providersLo onUpdate={onUpdate} onConfigure={onSelectTask} improvementDisabled={improvementDisabled} + orderStep={orderSteps[taskType]} /> ))}
diff --git a/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx b/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx index 8c0c05c63f..f984d7bb16 100644 --- a/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx +++ b/client/src/components/cos/tabs/schedule/GlobalConfigControls.jsx @@ -16,6 +16,7 @@ import useTaskModelPins from '../../../../hooks/useTaskModelPins'; import { effectiveModelFor, selectableProviders } from '../../../../utils/providers'; import EffortSelect from '../../EffortSelect'; import PromptEditor from './PromptEditor'; +import TaskDependencyPicker from './TaskDependencyPicker'; import RunTaskButton from './RunTaskButton'; import TaskDataInputs from '../../TaskDataInputs'; import { INTERVAL_DESCRIPTIONS, PERPETUAL_DESCRIPTION, toggleMetadataField, pipelineStages, IMPROVEMENT_DISABLED_TITLE, SAVING_TITLE, fileIssuesEffective, managedAgentOptionsFor, toggleFileIssuesMetadata } from './scheduleConstants'; @@ -50,6 +51,11 @@ export default function GlobalConfigControls({ taskType, config, onUpdate, onTri const [selectedType, setSelectedType] = useState(config.type); const [editingPrompt, setEditingPrompt] = useState(false); const [promptValue, setPromptValue] = useState(config.prompt || ''); + const labelsDraft = useFieldDraft((config.labels || []).join(', '), async next => { + setUpdating(true); + await onUpdate(taskType, { labels: next.split(',').map(label => label.trim()).filter(Boolean) }) + .finally(() => setUpdating(false)); + }); const descriptionDraft = useFieldDraft( config.description || '', async (next) => { @@ -258,6 +264,13 @@ export default function GlobalConfigControls({ taskType, config, onUpdate, onTri )} + + +

Comma-separated; up to 20 labels, 40 characters each. Shipped labels remain available. Saves on blur.

+
+ {allTaskTypes?.length > 1 && ( -
- Run After (dependencies) -
- {allTaskTypes.filter(t => t !== taskType).map(dep => { - const isSelected = (config.runAfter || []).includes(dep); - return ( - - ); - })} -
-

This task will wait for selected tasks to complete first within the same cycle

-
+ <> + {/* Advisory first: it answers "which of these do I run first?", which + is the question most users open this panel with. The enforced gate + below it is the rarer, heavier choice. */} + { + setUpdating(true); + // Sent as-is: an emptied list saves `[]`, which the server reads + // back as a deliberate clear rather than re-inheriting the + // shipped order (see normalizeSuggestedAfter). + onUpdate(taskType, { suggestedAfter: next }).finally(() => setUpdating(false)); + }} + /> + { + setUpdating(true); + onUpdate(taskType, { runAfter: next.length > 0 ? next : null }).finally(() => setUpdating(false)); + }} + /> + )}
diff --git a/client/src/components/cos/tabs/schedule/MaintenanceRunForm.jsx b/client/src/components/cos/tabs/schedule/MaintenanceRunForm.jsx new file mode 100644 index 0000000000..48296dae41 --- /dev/null +++ b/client/src/components/cos/tabs/schedule/MaintenanceRunForm.jsx @@ -0,0 +1,212 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router'; +import socket from '../../../../services/socket'; +import MaintenanceRunStatus from './MaintenanceRunStatus'; +import ProviderModelSelector from '../../../ProviderModelSelector'; +import * as api from '../../../../services/api'; +import { useAutoRefetch } from '../../../../hooks/useAutoRefetch'; +import { buildQuotaBurnTaskCatalog, maintenancePrerequisites, taskSourceHref } from '../../../../lib/quotaBurnTasks'; +import { getAppName } from '../../../../utils/formatters'; +import { effortAwareModelOptions, isProcessProvider } from '../../../../utils/providers'; +import { familyForProvider } from '../../../../../../server/lib/providerFamilies'; + +const RUNS_POLL_MS = 15_000; +const FINISHED_RUNS_SHOWN = 3; + +export default function MaintenanceRunForm({ schedule, apps = [], providers = [], providersLoaded, improvementDisabled, daemonRunning, onRefresh }) { + const [appId, setAppId] = useState(''); + const [providerId, setProviderId] = useState(''); + const [model, setModel] = useState(''); + const [effort, setEffort] = useState(''); + const [consent, setConsent] = useState(false); + const [busy, setBusy] = useState(false); + const [preparing, setPreparing] = useState(false); + const [message, setMessage] = useState(''); + // `null` = not read yet, `[]` = read and empty — the first read must happen + // even when there is nothing to show. + const [runs, setRuns] = useState(null); + const revision = useRef(0); + const availableProviders = providers.filter(provider => provider.enabled && isProcessProvider(provider) && familyForProvider(provider)); + const provider = availableProviders.find(entry => entry.id === providerId); + const groups = buildQuotaBurnTaskCatalog({ schedule, apps }); + const prerequisites = maintenancePrerequisites(groups, appId); + const ready = Boolean(appId) && prerequisites.length === 0; + const blocked = improvementDisabled || daemonRunning === false; + + const fetchRuns = useCallback(async () => { + const requestedRevision = revision.current; + const response = await api.getMaintenanceRuns({ silent: true }).catch(() => null); + // A failed read stays `null` so the poll keeps trying; only a real answer + // settles the list. + if (response && requestedRevision === revision.current) setRuns(response.runs || []); + }, []); + const anyRunning = (runs || []).some(run => run.status === 'running'); + // One read on mount, then polling only while a run is in flight — an idle + // form costs nothing, and a start/resume already holds the fresh record. + const { refetch: refreshRuns } = useAutoRefetch(fetchRuns, RUNS_POLL_MS, { pollOnly: true, enabled: runs === null || anyRunning, immediate: runs === null }); + + useEffect(() => { + const subscribe = () => { socket.emit('cos:subscribe'); fetchRuns(); }; + const update = updated => { + revision.current += 1; + setRuns(current => { + const previous = (current || []).find(entry => entry.id === updated.id); + if (previous?.updatedAt > updated.updatedAt) return current; + return [updated, ...(current || []).filter(entry => entry.id !== updated.id)]; + }); + }; + socket.on('cos:maintenance:updated', update); + socket.on('connect', subscribe); + socket.emit('cos:subscribe'); + return () => { + socket.off('cos:maintenance:updated', update); + socket.off('connect', subscribe); + }; + }, [fetchRuns]); + + const prepare = async () => { + if (busy || !onRefresh || !prerequisites.length || prerequisites.some(item => item.unavailable)) return; + setBusy(true); + setPreparing(true); + setMessage('Enabling required tasks…'); + const save = async () => { + for (const { taskType, settings, enableApp } of prerequisites) { + if (Object.keys(settings).length) { + const result = await api.updateCosTaskInterval(taskType, settings, { silent: true }); + if (!result?.success) throw new Error(`Could not update ${taskType}`); + } + if (enableApp) { + const result = await api.updateAppTaskTypeOverride(appId, taskType, { enabled: true }, { silent: true }); + if (!result?.success) throw new Error(`Could not enable ${taskType} for this app`); + } + } + return true; + }; + const saved = await save().catch(error => { + setMessage(`Setup incomplete: ${error.message}. Saved changes are kept; retry to finish.`); + return false; + }); + // Refresh even after a partial save so retries use the persisted settings. + const refreshed = await onRefresh().catch(() => false); + if (!refreshed) setMessage(current => saved + ? 'Could not refresh task settings. Refresh the schedule before running maintenance.' + : `${current} Refreshing the schedule also failed.`); + else if (saved) setMessage('Required task settings saved.'); + setPreparing(false); + setBusy(false); + }; + + const describe = result => (result?.dispatched + ? `Maintenance started with ${result.taskType}. Later steps follow as each one finishes; progress is shown below and in Tasks.` + : `Maintenance run saved; holding: ${result?.reason || 'nothing dispatched'}. It retries on its own.`); + + const run = async () => { + if (busy || blocked || !ready || !provider || !model || !consent) return; + setBusy(true); + setMessage('Starting maintenance…'); + const response = await api.startMaintenanceRun({ appId, providerId, model, effort: effort || null }, { silent: true }).catch(error => { + setMessage(`Could not start maintenance: ${error.message}`); + return null; + }); + if (response?.run) { + revision.current += 1; + setRuns(current => [response.run, ...(current || []).filter(entry => entry.id !== response.run.id)]); + setMessage(describe(response.result)); + setConsent(false); + } + setBusy(false); + }; + + const applyRun = (updated, result) => { + if (!updated) return; + revision.current += 1; + setRuns(current => (current || []).map(entry => (entry.id === updated.id ? updated : entry))); + if (result) setMessage(describe(result)); + }; + const stop = async id => { + const response = await api.stopMaintenanceRun(id, { silent: true }).catch(error => { + setMessage(`Could not stop the run: ${error.message}`); + return null; + }); + applyRun(response?.run); + }; + const resume = async id => { + const response = await api.resumeMaintenanceRun(id, { silent: true }).catch(error => { + setMessage(`Could not resume the run: ${error.message}`); + return null; + }); + applyRun(response?.run, response?.result); + }; + + const visibleRuns = [ + ...(runs || []).filter(entry => entry.status === 'running'), + ...(runs || []).filter(entry => entry.status !== 'running').slice(0, FINISHED_RUNS_SHOWN), + ]; + + return ( +
+ + { setProviderId(next); setModel(''); setEffort(''); }} + onModelChange={setModel} + effort={effort} + onEffortChange={setEffort} + emptyProviderOption="Select a subscription provider" + emptyModelOption="Select a model" + alwaysShowModel + loading={!providersLoaded} + disabled={busy} + /> + {appId && !ready &&
+

Run now needs these saved task settings:

+
    + {prerequisites.map(item =>
  • + {item.taskType}: {item.reason} +
  • )} +
+ {onRefresh && !prerequisites.some(item => item.unavailable) && <> +

Enable the listed tasks globally and for this app, and set claim-issue to perpetual. This also allows their existing schedules to run.

+ + } +
} + {blocked &&

Enable Improvement and start the CoS daemon before running maintenance.

} +

Runs every step now, in order: each audit starts when the previous step finishes, and each claim-issue drain repeats until the app’s issue backlog is empty. Independent of Quota Burn — no master switch, no quota gates, nothing to re-arm. Supports subscription CLI/TUI providers. Blank effort inherits each scheduled task’s saved effort.

+ +
+ + Schedule this sequence in Quota Burn instead +
+ {message &&

{message}

} + {visibleRuns.length > 0 &&
    + {visibleRuns.map(entry => { + return
  • + {getAppName(entry.appId, apps, entry.appId)} + {entry.providerId}{entry.model ? ` · ${entry.model}` : ''} + + {entry.status === 'running' + ? + : entry.status === 'stopped' && } +
  • ; + })} +
} + {visibleRuns.length > 0 && } +
+ ); +} diff --git a/client/src/components/cos/tabs/schedule/MaintenanceRunForm.test.jsx b/client/src/components/cos/tabs/schedule/MaintenanceRunForm.test.jsx new file mode 100644 index 0000000000..facb1076f5 --- /dev/null +++ b/client/src/components/cos/tabs/schedule/MaintenanceRunForm.test.jsx @@ -0,0 +1,176 @@ +import { useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router'; +import { MAINTENANCE_TASK_ORDER } from '../../../../lib/quotaBurnTasks'; +import { MAINTENANCE_SEQUENCE_TYPES } from '../../../../../../server/lib/maintenanceSequence'; +import MaintenanceRunForm from './MaintenanceRunForm'; + +const socket = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn(), emit: vi.fn() })); +vi.mock('../../../../services/socket', () => ({ default: socket })); +const api = vi.hoisted(() => ({ + getMaintenanceRuns: vi.fn(), startMaintenanceRun: vi.fn(), stopMaintenanceRun: vi.fn(), resumeMaintenanceRun: vi.fn(), + updateCosTaskInterval: vi.fn(), updateAppTaskTypeOverride: vi.fn(), +})); +vi.mock('../../../../services/api', () => api); +const tasks = Object.fromEntries([...MAINTENANCE_TASK_ORDER, 'claim-issue'].map(taskType => [taskType, { + enabled: true, perpetual: taskType === 'claim-issue', appOverrides: { example: { enabled: true } }, +}])); +const props = { + schedule: { tasks }, apps: [{ id: 'example', name: 'Example App' }], + providers: [{ id: 'claude', name: 'Claude', type: 'cli', command: 'claude', enabled: true, models: ['sonnet'] }], + providersLoaded: true, daemonRunning: true, +}; +const steps = MAINTENANCE_SEQUENCE_TYPES.map((taskType, index) => ({ id: `maint-1-${index}`, taskRef: { taskType } })); +const runRecord = (overrides = {}) => ({ + id: 'maint-1', appId: 'example', providerId: 'claude', model: 'sonnet', status: 'running', steps, + completed: { 'maint-1-0': 'done' }, active: { stepId: 'maint-1-1', taskType: 'claim-issue' }, reason: null, ...overrides, +}); +const show = (overrides = {}) => render(); +const select = async user => { + await user.selectOptions(screen.getByLabelText('App'), 'example'); + await user.selectOptions(screen.getByRole('combobox', { name: 'Provider' }), 'claude'); + await user.selectOptions(screen.getByRole('combobox', { name: 'Model' }), 'sonnet'); + await user.selectOptions(screen.getByRole('combobox', { name: /effort/i }), 'high'); + await user.click(screen.getByRole('checkbox')); +}; +beforeEach(() => { + vi.clearAllMocks(); + api.updateCosTaskInterval.mockResolvedValue({ success: true }); + api.updateAppTaskTypeOverride.mockResolvedValue({ success: true }); + api.getMaintenanceRuns.mockResolvedValue({ runs: [] }); + api.startMaintenanceRun.mockResolvedValue({ run: runRecord({ completed: {}, active: { stepId: 'maint-1-0', taskType: 'better-structural-drift' } }), result: { dispatched: true, taskType: 'better-structural-drift' } }); +}); +describe('maintenance launch', () => { + it('starts a standalone run with the app and pins, and shows its progress', async () => { + const user = userEvent.setup(); + let finishStart; + api.startMaintenanceRun.mockReturnValue(new Promise(resolve => { finishStart = resolve; })); + show(); + await select(user); + await user.click(screen.getByRole('button', { name: 'Run now' })); + expect(screen.getByRole('button', { name: 'Starting…' })).toBeDisabled(); + expect(api.startMaintenanceRun).toHaveBeenCalledWith({ appId: 'example', providerId: 'claude', model: 'sonnet', effort: 'high' }, { silent: true }); + finishStart({ run: runRecord(), result: { dispatched: true, taskType: 'better-structural-drift' } }); + expect(await screen.findByText(/Maintenance started with better-structural-drift/)).toBeInTheDocument(); + const row = screen.getByRole('list', { name: 'Maintenance runs' }); + expect(row).toHaveTextContent('Example App'); + expect(row).toHaveTextContent('running · 1/13 steps · claim-issue'); + expect(screen.getByRole('button', { name: 'Run now' })).toBeDisabled(); + expect(screen.getByRole('link', { name: /Quota Burn/ })).toHaveAttribute('href', '/devtools/quota-burn'); + }); + it('reports a saved-but-holding run and a failed start honestly', async () => { + const user = userEvent.setup(); + api.startMaintenanceRun.mockResolvedValueOnce({ run: runRecord({ completed: {}, active: null, reason: 'provider unavailable' }), result: { dispatched: false, reason: 'provider unavailable' } }); + show(); + await select(user); + await user.click(screen.getByRole('button', { name: 'Run now' })); + expect(await screen.findByText(/holding: provider unavailable/)).toBeInTheDocument(); + api.startMaintenanceRun.mockRejectedValueOnce(new Error('a maintenance run is already in progress')); + await user.click(screen.getByRole('checkbox')); + await user.click(screen.getByRole('button', { name: 'Run now' })); + expect(await screen.findByText(/Could not start maintenance: a maintenance run is already in progress/)).toBeInTheDocument(); + }); + it('stops and resumes an existing run from the list', async () => { + const user = userEvent.setup(); + api.getMaintenanceRuns.mockResolvedValue({ runs: [runRecord()] }); + api.stopMaintenanceRun.mockResolvedValue({ run: runRecord({ status: 'stopped', reason: 'stopped by the user' }) }); + api.resumeMaintenanceRun.mockResolvedValue({ run: runRecord(), result: { dispatched: true, taskType: 'claim-issue' } }); + show(); + await user.click(await screen.findByRole('button', { name: 'Stop' })); + expect(await screen.findByText(/stopped · 1\/13 steps/)).toBeInTheDocument(); + expect(api.stopMaintenanceRun).toHaveBeenCalledWith('maint-1', { silent: true }); + await user.click(screen.getByRole('button', { name: 'Resume' })); + expect(await screen.findByText(/running · 1\/13 steps/)).toBeInTheDocument(); + expect(screen.getByText(/Maintenance started with claim-issue/)).toBeInTheDocument(); + }); + it('blocks missing task eligibility and a stopped daemon', async () => { + const user = userEvent.setup(); + show({ schedule: { tasks: {} }, daemonRunning: false, onRefresh: vi.fn() }); + await select(user); + expect(screen.getByRole('button', { name: 'Run now' })).toBeDisabled(); + expect(screen.getByText(/Run now needs these saved task settings/)).toBeInTheDocument(); + expect(screen.getByText(/start the CoS daemon/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Enable required tasks' })).not.toBeInTheDocument(); + expect(api.startMaintenanceRun).not.toHaveBeenCalled(); + }); +}); + +// Regression: a valid provider/model selection must have a path out of the +// disabled launch state, and only persisted prerequisites may unlock launch. +it('enables only missing prerequisites and waits for refreshed saved settings before launch', async () => { + const user = userEvent.setup(); + const incomplete = { ...tasks, + simplify: { ...tasks.simplify, enabled: false, appOverrides: {} }, + 'claim-issue': { ...tasks['claim-issue'], perpetual: false }, + }; + let finishRefresh; + const refreshed = new Promise(resolve => { finishRefresh = resolve; }); + function Harness() { + const [schedule, setSchedule] = useState({ tasks: incomplete }); + return { + await refreshed; + setSchedule({ tasks }); + return { tasks }; + }} />; + } + render(); + await select(user); + expect(screen.getByRole('button', { name: 'Run now' })).toBeDisabled(); + expect(screen.getByRole('link', { name: 'simplify' })).toHaveAttribute('href', '/cos/schedule?task=simplify'); + expect(screen.getByText(/disabled globally; disabled for this app/)).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Enable required tasks' })); + await waitFor(() => expect(api.updateCosTaskInterval).toHaveBeenCalledTimes(2)); + expect(api.updateCosTaskInterval).toHaveBeenCalledWith('simplify', { enabled: true }, { silent: true }); + expect(api.updateCosTaskInterval).toHaveBeenCalledWith('claim-issue', { perpetual: true }, { silent: true }); + expect(api.updateAppTaskTypeOverride).toHaveBeenCalledExactlyOnceWith('example', 'simplify', { enabled: true }, { silent: true }); + expect(screen.getByRole('button', { name: 'Run now' })).toBeDisabled(); + expect(screen.getByRole('combobox', { name: 'App' })).toBeDisabled(); + expect(api.startMaintenanceRun).not.toHaveBeenCalled(); + finishRefresh(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Run now' })).toBeEnabled()); + await user.click(screen.getByRole('button', { name: 'Run now' })); + expect(await screen.findByText(/Maintenance started/)).toBeInTheDocument(); +}); + +it('refreshes partial setup after failure without starting work', async () => { + const user = userEvent.setup(); + const onRefresh = vi.fn().mockRejectedValue(new Error('refresh unavailable')); + api.updateAppTaskTypeOverride.mockRejectedValueOnce(new Error('save unavailable')); + show({ schedule: { tasks: { ...tasks, simplify: { ...tasks.simplify, enabled: false, appOverrides: {} } } }, onRefresh }); + await select(user); + await user.click(screen.getByRole('button', { name: 'Enable required tasks' })); + expect(await screen.findByText(/Setup incomplete: save unavailable/)).toBeInTheDocument(); + expect(onRefresh).toHaveBeenCalledOnce(); + expect(screen.getByText(/Refreshing the schedule also failed/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Run now' })).toBeDisabled(); + expect(api.startMaintenanceRun).not.toHaveBeenCalled(); +}); + +it('streams agent activity and completed steps, and removes listeners on unmount', async () => { + api.getMaintenanceRuns.mockResolvedValue({ runs: [runRecord({ completed: {} })] }); + const view = show(); + await screen.findByRole('list', { name: 'Maintenance runs' }); + const update = socket.on.mock.calls.find(([name]) => name === 'cos:maintenance:updated')[1]; + act(() => update(runRecord({ completed: {}, active: { taskType: 'better-structural-drift', status: 'running', agentId: 'agent-example' } }))); + expect(screen.getByRole('link', { name: 'Open agent in new tab' })).toHaveAttribute('href', '/cos/agents/agent-example'); + expect(screen.getByRole('link', { name: 'Open agent in new tab' })).toHaveAttribute('target', '_blank'); + act(() => update(runRecord())); + expect(screen.getByRole('progressbar')).toHaveAttribute('value', '1'); + view.unmount(); + expect(socket.off).toHaveBeenCalledWith('cos:maintenance:updated', update); +}); + +it('keeps a newer live update when an older initial fetch resolves late, and refreshes on reconnect', async () => { + let resolveRead; + api.getMaintenanceRuns.mockReturnValueOnce(new Promise(resolve => { resolveRead = resolve; })); + show(); + const update = socket.on.mock.calls.find(([name]) => name === 'cos:maintenance:updated')[1]; + act(() => update(runRecord())); + await act(async () => resolveRead({ runs: [runRecord({ completed: {} })] })); + expect(screen.getByRole('progressbar')).toHaveAttribute('value', '1'); + api.getMaintenanceRuns.mockResolvedValue({ runs: [runRecord({ status: 'completed', active: null })] }); + await act(async () => socket.on.mock.calls.find(([name]) => name === 'connect')[1]()); + expect(screen.getByText(/completed · 1\/13 steps/)).toBeInTheDocument(); +}); diff --git a/client/src/components/cos/tabs/schedule/MaintenanceRunStatus.jsx b/client/src/components/cos/tabs/schedule/MaintenanceRunStatus.jsx new file mode 100644 index 0000000000..b3161abc55 --- /dev/null +++ b/client/src/components/cos/tabs/schedule/MaintenanceRunStatus.jsx @@ -0,0 +1,16 @@ +/** Shared live progress content for the schedule card and corner notification. */ +export default function MaintenanceRunStatus({ run }) { + const done = Object.keys(run.completed || {}).length; + const total = run.steps?.length || 0; + const step = run.active?.taskType || run.steps?.find(entry => !run.completed?.[entry.id])?.taskRef?.taskType; + return
+

{run.status} · {done}/{total} steps{run.status === 'running' && step ? ` · ${step}` : ''}

+ + {run.active &&

+ {run.active.status === 'running' &&

} + {run.reason &&

{run.reason}

} +
; +} diff --git a/client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx b/client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx index a7a954e060..b4f53fe91d 100644 --- a/client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx +++ b/client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx @@ -149,14 +149,15 @@ export default function PipelineStageConfig({ taskType, config, providers, provi )}
{stages.map((stage, i) => { - const stageProvider = providers?.find(p => p.id === stage.providerId); const role = needsSecurityModelPolicy ? (prReviewerStageRole(stage) || (i === 0 ? 'security' : i === 1 ? 'eligibility' : 'actions')) : null; const isSecurityStage = role === 'security'; + const selection = isSecurityStage ? stage.largeInputFallback || {} : stage; + const stageProvider = providers?.find(p => p.id === selection.providerId); // PR roles reassert the server's no-tool contract even for legacy // profiles or position-only stages. Other pipelines keep their profile. - const posture = isSecurityStage ? null : stagePublicReviewPosture(role ? { ...stage, role } : stage); + const posture = isSecurityStage ? (stage.largeInputFallback ? PUBLIC_REVIEW_NO_TOOL_POSTURE : null) : stagePublicReviewPosture(role ? { ...stage, role } : stage); const isNoToolStage = posture === PUBLIC_REVIEW_NO_TOOL_POSTURE; const isActionsStage = Boolean(posture) && !isNoToolStage; const eligibleProviders = posture ? eligibleProvidersFor(providers, selectionPolicies[posture]) : null; @@ -172,13 +173,24 @@ export default function PipelineStageConfig({ taskType, config, providers, provi })); const stageModels = posture && localBackend && (isNoToolStage || localStageModels.length > 0) ? localStageModels - : effortAwareModelOptions(stageProvider, stage.model); + : effortAwareModelOptions(stageProvider, selection.model); const selectionPolicy = posture ? selectionPolicies[posture] : undefined; - const stageProviderId = stage.providerId || ''; - const stageModel = stage.model || ''; - const stageEffort = stage.effort || ''; + const stageProviderId = selection.providerId || ''; + const stageModel = selection.model || ''; + const stageEffort = selection.effort || ''; - const updateStage = (field, value) => handleStageUpdate(i, field, value || null); + const updateStage = (field, value) => { + if (!isSecurityStage) return handleStageUpdate(i, field, value || null); + const fallback = { ...selection }; + if (value) fallback[field] = value; + else delete fallback[field]; + if (field === 'providerId') { + delete fallback.model; + delete fallback.effort; + } + if (field === 'model' && !effortSurvivingModel(stageProvider, value, fallback.effort)) delete fallback.effort; + return handleStageUpdate(i, 'largeInputFallback', fallback); + }; return (
@@ -186,7 +198,7 @@ export default function PipelineStageConfig({ taskType, config, providers, provi {stage.readOnly && ( read-only )} - {isNoToolStage && ( + {isNoToolStage && !isSecurityStage && ( {role === 'actions' ? 'tool-free review' : 'tool-free gate'} )} {isActionsStage && ( @@ -207,7 +219,25 @@ export default function PipelineStageConfig({ taskType, config, providers, provi

)} - {!isSecurityStage && ( + {isSecurityStage && ( +
+ handleStageUpdate(i, 'largeInputFallback', stage.largeInputFallback ? null : {})} + disabled={updating} + ariaLabel="Enable larger-model fallback for oversized linked issues" + size="sm" + /> +

Larger-model fallback for oversized linked issues. Choose a provider and a model with enough context for the complete PR and issues. Issues over 8,000 characters (up to 65,536 each) use this selection for the eligibility gate and final review. Security screening still runs on all content; flagged or incomplete input stays blocked.

+ {stage.largeInputFallback && eligibleProviders?.length === 0 && ( +

No enabled tool-free provider is available for the fallback. Oversized linked issues will block the run until one is configured.

+ )} + {stage.largeInputFallback && (!selection.providerId || !selection.model) && ( +

Select both a provider and a model to enable fallback runs.

+ )} +
+ )} + {(!isSecurityStage || stage.largeInputFallback) && ( updateStage('model', model)} effort={stageEffort} onEffortChange={(effort) => updateStage('effort', effort)} - emptyProviderOption={posture + emptyProviderOption={isSecurityStage ? 'Select fallback provider' : posture ? 'First eligible provider on this install' : 'Default (task-level)'} - emptyModelOption={posture ? 'Use provider default model' : 'Default (task-level)'} + emptyModelOption={isSecurityStage ? 'Select larger model' : posture ? 'Use provider default model' : 'Default (task-level)'} alwaysShowModel selectionPolicy={selectionPolicy} disabled={updating} /> )} - {posture && eligibleProviders?.length === 0 && ( + {posture && !isSecurityStage && eligibleProviders?.length === 0 && (

No enabled AI provider on this install can enforce the{' '} {isActionsStage ? 'sandboxed-actions' : 'tool-free'} posture, so this stage will not run. @@ -235,7 +265,7 @@ export default function PipelineStageConfig({ taskType, config, providers, provi Settings → Providers.

)} - {isNoToolStage && eligibleProviders?.length > 0 && ( + {isNoToolStage && !isSecurityStage && eligibleProviders?.length > 0 && (

{localModelsLoading ? 'Loading installed local model capability reports…' diff --git a/client/src/components/cos/tabs/schedule/PipelineStageConfig.test.jsx b/client/src/components/cos/tabs/schedule/PipelineStageConfig.test.jsx index 27bb8913d1..4f0dabdb9b 100644 --- a/client/src/components/cos/tabs/schedule/PipelineStageConfig.test.jsx +++ b/client/src/components/cos/tabs/schedule/PipelineStageConfig.test.jsx @@ -119,6 +119,17 @@ describe('PipelineStageConfig — pr-reviewer', () => { expect(screen.queryByText(/applies only the screened patch/)).not.toBeInTheDocument(); }); + it('configures and clears the larger-model fallback through the standard picker', async () => { + const fallbackStages = STAGES.map((stage, index) => index === 0 ? { ...stage, largeInputFallback: { providerId: 'claude-ollama' } } : stage); + const onUpdate = renderStages(fallbackStages); + fireEvent.change(screen.getAllByLabelText('Model')[0], { target: { value: 'safe-model' } }); + await waitFor(() => expect(onUpdate).toHaveBeenCalledWith('pr-reviewer', expect.objectContaining({ + taskMetadata: { pipeline: { stages: [expect.objectContaining({ largeInputFallback: { providerId: 'claude-ollama', model: 'safe-model' } }), ...STAGES.slice(1)] } }, + }))); + fireEvent.click(screen.getByRole('switch', { name: 'Enable larger-model fallback for oversized linked issues' })); + await waitFor(() => expect(onUpdate).toHaveBeenLastCalledWith('pr-reviewer', { taskMetadata: { pipeline: { stages: STAGES } } })); + }); + it('removes the optional actions stage without changing the mandatory gate', async () => { const onUpdate = renderStages(); fireEvent.click(screen.getByRole('switch', { name: 'Enable final code review and actions' })); diff --git a/client/src/components/cos/tabs/schedule/RunTaskButton.jsx b/client/src/components/cos/tabs/schedule/RunTaskButton.jsx index 114f2a5cf4..6781424026 100644 --- a/client/src/components/cos/tabs/schedule/RunTaskButton.jsx +++ b/client/src/components/cos/tabs/schedule/RunTaskButton.jsx @@ -153,7 +153,7 @@ export default function RunTaskButton({ taskType, apps, onTrigger, installWide = {open && !disabled && createPortal(

{config && (
- + {activeTab === 'stages' && hasStages && ( option !== taskType && !selected.includes(option)) + .sort((a, b) => a.localeCompare(b)); + + const commit = (next) => { + setPending(''); + onChange(next); + }; + + // FormField owns the label/hint and the htmlFor pairing, so the chips sit + // under the control it labels rather than between the two. + return ( + + + {selected.length > 0 && ( +
+ {selected.map(dep => ( + + {dep} + + + ))} +
+ )} +
+ ); +} diff --git a/client/src/components/cos/tabs/schedule/TaskDependencyPicker.test.jsx b/client/src/components/cos/tabs/schedule/TaskDependencyPicker.test.jsx new file mode 100644 index 0000000000..036ef847c1 --- /dev/null +++ b/client/src/components/cos/tabs/schedule/TaskDependencyPicker.test.jsx @@ -0,0 +1,46 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import TaskDependencyPicker from './TaskDependencyPicker'; + +const OPTIONS = ['simplify', 'module-hygiene', 'better-complexity', 'self']; + +function renderPicker(props = {}) { + const onChange = vi.fn(); + render( + + ); + return { onChange }; +} + +describe('TaskDependencyPicker', () => { + it('offers only unselected tasks, and never the task itself', () => { + renderPicker(); + const select = screen.getByLabelText('Suggested order (advisory)'); + const values = [...select.options].map(option => option.value); + expect(values).toEqual(['', 'better-complexity', 'module-hygiene']); + }); + + it('appends the chosen task to the list', () => { + const { onChange } = renderPicker(); + fireEvent.change(screen.getByLabelText('Suggested order (advisory)'), { target: { value: 'module-hygiene' } }); + expect(onChange).toHaveBeenCalledWith(['simplify', 'module-hygiene']); + }); + + it('removes a chip, handing back an empty list rather than nothing', () => { + const { onChange } = renderPicker(); + fireEvent.click(screen.getByLabelText('Remove simplify')); + expect(onChange).toHaveBeenCalledWith([]); + }); + + it('disables the roster when there is nothing left to add', () => { + renderPicker({ value: ['simplify', 'module-hygiene', 'better-complexity'] }); + expect(screen.getByLabelText('Suggested order (advisory)')).toBeDisabled(); + }); +}); diff --git a/client/src/components/cos/tabs/schedule/TaskHeader.jsx b/client/src/components/cos/tabs/schedule/TaskHeader.jsx index 37d8652bf0..a5ab7e51dc 100644 --- a/client/src/components/cos/tabs/schedule/TaskHeader.jsx +++ b/client/src/components/cos/tabs/schedule/TaskHeader.jsx @@ -1,11 +1,11 @@ -import { GitBranch, GitMerge, Users } from 'lucide-react'; -import { badge, statusDot, getTaskStatusGroup, pipelineStages } from './scheduleConstants'; +import { GitBranch, GitMerge, ListOrdered, Users } from 'lucide-react'; +import { taskLabels, badge, statusDot, getTaskStatusGroup, pipelineStages } from './scheduleConstants'; import IntervalBadge from './IntervalBadge'; // Shared task identity row — status dot, monospace name, pipeline + swarm // badges, and interval badge. Used by both the schedule card and the config // drawer so the header stays consistent in one place. -export default function TaskHeader({ taskType, config }) { +export default function TaskHeader({ taskType, config, orderStep }) { const group = getTaskStatusGroup(config); const stages = pipelineStages(config); const invocation = config.invocation; @@ -15,6 +15,10 @@ export default function TaskHeader({ taskType, config }) { // shows its own override select). const swarmCount = config.taskMetadata?.swarmCount; const swarmOn = Number.isInteger(swarmCount) && swarmCount >= 2; + // Advisory predecessors, named by task type. `orderStep` is passed in rather + // than read off the config: it is ranked across the whole schedule, which a + // single task config cannot know. + const suggestedAfter = Array.isArray(config.suggestedAfter) ? config.suggestedAfter : []; const branchesPerAgent = config.taskMetadata?.branchesPerAgent; const branchBatchOn = taskType === 'branch-reconcile' && Number.isInteger(branchesPerAgent) && branchesPerAgent > 0; return ( @@ -22,7 +26,7 @@ export default function TaskHeader({ taskType, config }) {
{automationOnly && ( @@ -33,6 +37,20 @@ export default function TaskHeader({ taskType, config }) { {invocation.label || 'Automation-only'} )} + {/* Where this task falls in the advisory order — only for tasks that + actually participate in it, so an unordered task isn't mislabeled + as "the first thing to run". */} + {Number.isInteger(orderStep) && ( + 0 + ? `Suggested order step ${orderStep} — run ${suggestedAfter.join(', ')} first (advisory, never enforced)` + : `Suggested order step ${orderStep} — nothing is suggested before this one`} + > + + {orderStep} + + )} {swarmOn && ( @@ -57,6 +75,21 @@ export default function TaskHeader({ taskType, config }) { {config.description && (

{config.description}

)} + {/* The chips name real scheduled task types; `runGuidance` below is the why. */} + {suggestedAfter.length > 0 && ( +

+ Run first: + {suggestedAfter.map(dep => ( + {dep} + ))} +

+ )} + {config.runGuidance &&

{config.runGuidance}

} + {taskLabels(config).length > 0 && ( +
+ {taskLabels(config).map(label => {label})} +
+ )} {automationOnly && invocation.description && (

{invocation.description}

)} diff --git a/client/src/components/cos/tabs/schedule/TaskHeader.test.jsx b/client/src/components/cos/tabs/schedule/TaskHeader.test.jsx new file mode 100644 index 0000000000..3bb91cb3b7 --- /dev/null +++ b/client/src/components/cos/tabs/schedule/TaskHeader.test.jsx @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import TaskHeader from './TaskHeader'; + +const baseConfig = { enabled: true, type: 'on-demand' }; + +function renderHeader(overrides = {}, orderStep = undefined, taskType = 'better-complexity') { + render(); +} + +describe('TaskHeader advisory run order', () => { + it('names the tasks to run first instead of describing them in prose', () => { + renderHeader({ suggestedAfter: ['module-hygiene'] }, 4); + expect(screen.getByText('Run first:')).toBeInTheDocument(); + expect(screen.getByText('module-hygiene')).toBeInTheDocument(); + }); + + it('shows the order step for a task inside the sequence', () => { + renderHeader({ suggestedAfter: ['module-hygiene'] }, 4); + expect(screen.getByTitle(/Suggested order step 4 — run module-hygiene first/)).toBeInTheDocument(); + }); + + it('shows no step for a task outside the sequence — unordered is not "first"', () => { + renderHeader({}, undefined, 'claim-issue'); + expect(screen.queryByTitle(/Suggested order step/)).not.toBeInTheDocument(); + expect(screen.queryByText('Run first:')).not.toBeInTheDocument(); + }); + + it('keeps the rationale prose as a separate line from the named order', () => { + renderHeader({ suggestedAfter: ['module-hygiene'], runGuidance: 'Remeasures the functions that survived the ladder.' }, 4); + expect(screen.getByText('Remeasures the functions that survived the ladder.')).toBeInTheDocument(); + expect(screen.getByText('Run first:')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/cos/tabs/schedule/scheduleConstants.js b/client/src/components/cos/tabs/schedule/scheduleConstants.js index 7feef45f79..feffbfe211 100644 --- a/client/src/components/cos/tabs/schedule/scheduleConstants.js +++ b/client/src/components/cos/tabs/schedule/scheduleConstants.js @@ -154,6 +154,69 @@ export function taskSortKey(taskType, config) { return { order: STATUS_GROUPS[group]?.order ?? 9, next: Number.isFinite(next) ? next : Infinity, taskType }; } +// The two card orderings the grid offers. "Status" is the long-standing +// default; "Suggested order" answers "which of these do I run first?" using the +// advisory `suggestedAfter` list each task carries. +export const TASK_SORTS = [ + { id: 'status', label: 'Status' }, + { id: 'suggested-order', label: 'Suggested order' }, +]; +export const DEFAULT_SORT_ID = TASK_SORTS[0].id; + +/** + * Rank the tasks that PARTICIPATE in the advisory `suggestedAfter` graph into a + * 1-based step — one past the highest step among the predecessors present here. + * + * Derived on the client, over the tasks actually being shown, because the step + * is a property of the whole graph rather than of one task: a server-sent + * per-task number goes stale the instant any OTHER task's list is edited, and + * ranking server-side would also number a card whose step-1 predecessor is + * hidden by a disabled instance feature. + * + * A task nobody orders and that orders nothing gets NO step — it sits outside + * the sequence, and calling every unordered task "step 1" would drown the + * answer. Edges naming an absent task are ignored; a user-declared cycle simply + * stops improving once the pass budget runs out (nothing enforces these). + */ +export function suggestedOrderSteps(tasks) { + const entries = Object.entries(tasks || {}); + const known = new Set(entries.map(([taskType]) => taskType)); + const deps = new Map(entries.map(([taskType, config]) => [ + taskType, + (Array.isArray(config?.suggestedAfter) ? config.suggestedAfter : []).filter(dep => dep !== taskType && known.has(dep)), + ])); + const participants = new Set([...deps].flatMap(([taskType, after]) => (after.length ? [taskType, ...after] : []))); + + const steps = Object.fromEntries([...participants].map(taskType => [taskType, 1])); + let changed = true; + let passes = 0; + while (changed && passes <= participants.size) { + changed = false; + passes++; + for (const taskType of participants) { + const step = deps.get(taskType).reduce((max, dep) => Math.max(max, steps[dep] + 1), 1); + if (step !== steps[taskType]) { + steps[taskType] = step; + changed = true; + } + } + } + return steps; +} + +// Comparator for the default "Status" view: group order, then soonest next run. +export function compareByStatus([aType, aConfig], [bType, bConfig]) { + const a = taskSortKey(aType, aConfig); + const b = taskSortKey(bType, bConfig); + return a.order - b.order || a.next - b.next || a.taskType.localeCompare(b.taskType); +} + +// Comparator for the "Suggested order" view, over a `suggestedOrderSteps` map. +// Tasks OUTSIDE the advisory order sort last rather than first — they carry no +// ordering claim, and putting them on top would bury the answer. +export const compareBySuggestedOrder = (steps) => ([aType], [bType]) => + (steps[aType] ?? Infinity) - (steps[bType] ?? Infinity) || aType.localeCompare(bType); + // Tailwind tone for the per-task app-coverage bar/label (error none, success full, warning partial). export function coverageTone(enabled, total) { if (enabled === 0) return { text: 'text-port-error', bar: 'bg-port-error' }; @@ -295,3 +358,6 @@ export function toggleMetadataField(metadata, field) { } return newMeta; } + +// Shipped clusters remain discoverable alongside the operator's own labels. +export const taskLabels = config => [...new Set([...(config.defaultLabels || []), ...(config.labels || [])])]; diff --git a/client/src/components/cos/tabs/schedule/scheduleConstants.test.js b/client/src/components/cos/tabs/schedule/scheduleConstants.test.js index 9bf8999546..a87dd0212e 100644 --- a/client/src/components/cos/tabs/schedule/scheduleConstants.test.js +++ b/client/src/components/cos/tabs/schedule/scheduleConstants.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { getTaskStatusGroup, taskSortKey, TASK_FILTERS, STATUS_GROUPS, describeNextRun, coverageTone, setMetadataOverride, toggleMetadataField, fileIssuesEffective, managedAgentOptionsFor, toggleFileIssuesMetadata, prReviewerStageRole, stagePublicReviewPosture, togglePrReviewerActions } from './scheduleConstants'; +import { getTaskStatusGroup, taskSortKey, TASK_FILTERS, STATUS_GROUPS, describeNextRun, coverageTone, setMetadataOverride, toggleMetadataField, fileIssuesEffective, managedAgentOptionsFor, toggleFileIssuesMetadata, prReviewerStageRole, stagePublicReviewPosture, togglePrReviewerActions, suggestedOrderSteps, compareBySuggestedOrder } from './scheduleConstants'; describe('pr-reviewer pipeline helpers', () => { it('recognizes semantic roles and legacy prompt-key stages', () => { @@ -284,3 +284,37 @@ describe('coverageTone', () => { }); }); // @vitest-environment node + +describe('suggestedOrderSteps', () => { + it('ranks a chain one step past its latest predecessor', () => { + const steps = suggestedOrderSteps({ + a: {}, + b: { suggestedAfter: ['a'] }, + c: { suggestedAfter: ['b'] }, + d: { suggestedAfter: ['a', 'c'] }, + }); + expect(steps).toEqual({ a: 1, b: 2, c: 3, d: 4 }); + }); + + it('leaves a task that neither names nor is named unranked, not "step 1"', () => { + const steps = suggestedOrderSteps({ ordered: { suggestedAfter: ['head'] }, head: {}, loner: {} }); + expect(steps.loner).toBeUndefined(); + expect(steps).toMatchObject({ head: 1, ordered: 2 }); + }); + + it('ignores an edge naming a task this install does not have', () => { + expect(suggestedOrderSteps({ here: { suggestedAfter: ['from-a-newer-peer'] } })).toEqual({}); + }); + + it('terminates on a user-declared cycle instead of hanging', () => { + const steps = suggestedOrderSteps({ a: { suggestedAfter: ['b'] }, b: { suggestedAfter: ['a'] } }); + expect(Number.isInteger(steps.a)).toBe(true); + expect(Number.isInteger(steps.b)).toBe(true); + }); + + it('sorts unranked tasks last — they make no ordering claim', () => { + const steps = { late: 3, early: 1 }; + const sorted = [['unranked', {}], ['late', {}], ['early', {}]].sort(compareBySuggestedOrder(steps)); + expect(sorted.map(([taskType]) => taskType)).toEqual(['early', 'late', 'unranked']); + }); +}); diff --git a/client/src/components/creative-director/CreativeDirectorModelsDrawer.jsx b/client/src/components/creative-director/CreativeDirectorModelsDrawer.jsx index 3380e699da..081793a7ee 100644 --- a/client/src/components/creative-director/CreativeDirectorModelsDrawer.jsx +++ b/client/src/components/creative-director/CreativeDirectorModelsDrawer.jsx @@ -307,7 +307,7 @@ export default function CreativeDirectorModelsDrawer({ open, onClose, project, o className="bg-port-card border border-port-border rounded px-2 py-2 text-sm text-white" > - {providerOptions.map((p) => )} + {providerOptions.map((p) => )}
diff --git a/client/src/components/creative-director/VideoExecutionPanel.jsx b/client/src/components/creative-director/VideoExecutionPanel.jsx index 53fad2218a..3e1e9be970 100644 --- a/client/src/components/creative-director/VideoExecutionPanel.jsx +++ b/client/src/components/creative-director/VideoExecutionPanel.jsx @@ -80,6 +80,9 @@ export default function VideoExecutionPanel({ project, onChange, basePath }) {
)} } - +
+ +

Use after changing models or provider settings to reload the selections above and check whether production is ready to start. Your limits stay as entered; this does not start production.

+
; } diff --git a/client/src/components/eidoverse/EidoverseTravel.jsx b/client/src/components/eidoverse/EidoverseTravel.jsx index 4b0513b6dd..9c4402332a 100644 --- a/client/src/components/eidoverse/EidoverseTravel.jsx +++ b/client/src/components/eidoverse/EidoverseTravel.jsx @@ -53,15 +53,16 @@ export default function EidoverseTravel({ travelRef, enabled, objects = [], onDe travelRef.current = depart; return () => { travelRef.current = null; }; }, [depart, travelRef]); - if (!enabled) return null; + if (!enabled || (!destinations.length && !error)) return null; return
- Federation Terminal · Use a pod in-world or choose a destination + {Boolean(destinations.length) && ( + Federation Terminal · Use a pod in-world or choose a destination + )} {destinations.map((destination) => )} - {!destinations.length && No connected guest worlds available} {error &&

{error}

}
; } diff --git a/client/src/components/meatspace/post/MorseTrainer.jsx b/client/src/components/meatspace/post/MorseTrainer.jsx index 0d6b4cab51..ffdda52431 100644 --- a/client/src/components/meatspace/post/MorseTrainer.jsx +++ b/client/src/components/meatspace/post/MorseTrainer.jsx @@ -457,6 +457,12 @@ export default function MorseTrainer({ mode = null, onSelectMode, onExitMode, on // level unlock) minus the on-screen morse hints and the reference cheat // sheet — the only meaningful difference the issue asks for. const showReference = mode !== 'head-copy'; + // Reference tab lives in the `?ref=` search param so it's deep-linkable and + // survives reload/share; a stale value degrades to 'tree'. It's owned here + // rather than inside ReferenceWidget because it also decides the page layout: + // the tree needs the full width, the other two views sit in a side column. + const [refView, setRefView] = useDrawerTab('ref', 'tree', REFERENCE_VIEW_IDS); + const sideBySideReference = showReference && refView !== 'tree'; // Hydrate the authoritative Koch level from the server (localStorage is now a // write-through cache). One-time adoption: if the server has never had a level @@ -565,7 +571,7 @@ export default function MorseTrainer({ mode = null, onSelectMode, onExitMode, on so the guard belongs on the grid that holds them, not on either one. Off send mode nothing owns Space and this is inert. */}
@@ -582,7 +588,7 @@ export default function MorseTrainer({ mode = null, onSelectMode, onExitMode, on )} {!mode && }
- {showReference && } + {showReference && }
); @@ -704,16 +710,17 @@ export const REFERENCE_VIEWS = [ const REFERENCE_VIEW_IDS = REFERENCE_VIEWS.map((v) => v.id); -function ReferenceWidget({ keying, mode }) { - // Reference tab lives in the `?ref=` search param so it's deep-linkable and - // survives reload/share; a stale value degrades to 'tree'. - const [view, setView] = useDrawerTab('ref', 'tree', REFERENCE_VIEW_IDS); +function ReferenceWidget({ keying, mode, view, setView }) { // Only show the in-progress key path in Send mode — in Copy mode the right // widget is a passive cheat-sheet, not live feedback for the user. const currentPath = mode === 'send' ? keying.pattern : ''; + // The tree needs the whole page width to render all 41 characters legibly, so + // it stacks under the drill instead of sharing the sticky side column. + const sideColumn = view !== 'tree'; + return ( -
+
{REFERENCE_VIEWS.map((v) => { @@ -755,21 +762,43 @@ export function isNodeOnPath(node, currentPath) { return { matched, onPath }; } +// Nodes are SVG (not absolutely-positioned divs) so the whole tree lives in one +// `viewBox` and scales with its container — at the tree tab's full-width desktop +// layout every one of the 41 characters is on screen at once instead of hiding +// behind a horizontal scrollbar. function TreeNodeLabel({ x, depth, node, currentPath }) { const { matched, onPath } = isNodeOnPath(node, currentPath); const display = node.char || (node.code === '' ? '·' : ''); + const cx = x * TREE_SLOT_W + TREE_SLOT_W / 2; + const cy = depth * TREE_ROW_H + TREE_ROW_H / 2; + const label = display || '·'; + const boxW = label.length * 7 + 8; return ( -
- {display || '·'} -
+ + + + {label} + + ); } @@ -785,28 +814,34 @@ function TreeView({ currentPath, mode }) { start dit →
-
-
- - {edges.map((e, i) => { - const highlighted = currentPath.length > 0 && currentPath.startsWith(e.childCode); - return ( - - ); - })} - +
+ {/* Capped at its natural pixel size so a wide viewport doesn't blow the + glyphs up; below that it shrinks to fit rather than overflowing. */} + + {edges.map((e, i) => { + const highlighted = currentPath.length > 0 && currentPath.startsWith(e.childCode); + return ( + + ); + })} {nodes.map(({ x, depth, node }) => ( ))} -
+

{mode === 'send' diff --git a/client/src/components/meatspace/post/MorseTrainer.test.jsx b/client/src/components/meatspace/post/MorseTrainer.test.jsx index 52b1e36898..ddbe0e27b7 100644 --- a/client/src/components/meatspace/post/MorseTrainer.test.jsx +++ b/client/src/components/meatspace/post/MorseTrainer.test.jsx @@ -443,12 +443,36 @@ describe('MorseTrainer Tree reference view', () => { // Regression guard for the reported bug: the root previously rendered // displaced toward the DIT side. Its computed x-slot should sit near the // horizontal middle of the tree's total width, not near either edge. - const treeContainer = root.closest('.relative.mx-auto'); - const totalWidth = parseFloat(treeContainer.style.width); - const rootLeft = parseFloat(root.style.left); + const svg = root.closest('svg'); + const totalWidth = parseFloat(svg.getAttribute('viewBox').split(' ')[2]); + const rootLeft = parseFloat(root.querySelector('text').getAttribute('x')); expect(rootLeft).toBeGreaterThan(totalWidth * 0.3); expect(rootLeft).toBeLessThan(totalWidth * 0.7); }); + + // Regression guard: the tree used to be a fixed-pixel-width block inside an + // `overflow-x-auto` side column, so most of the 41 characters were off-screen + // behind a scrollbar on desktop. It must now scale to whatever width it gets. + it('renders the whole tree as a scalable viewBox with no horizontal scroller', async () => { + const { container } = await renderMorse({ mode: null, onSelectMode: vi.fn() }); + const svg = container.querySelector('[title="start"]').closest('svg'); + expect(svg.getAttribute('viewBox')).toMatch(/^0 0 \d+ \d+$/); + expect(svg.getAttribute('class')).toContain('w-full'); + for (let el = svg.parentElement; el && el !== container; el = el.parentElement) { + expect(el.className).not.toMatch(/overflow-x-auto/); + } + }); + + // The tree gets the full page width; the compact Length/List views keep the + // narrow sticky side column beside the drill. + it('stacks the tree full-width but keeps the other reference views in a side column', async () => { + const treeRender = await renderMorse({ mode: null, onSelectMode: vi.fn() }, { route: '/post/morse?ref=tree' }); + expect(treeRender.container.querySelector('.xl\\:grid-cols-\\[minmax\\(0\\,1fr\\)_24rem\\]')).toBeNull(); + treeRender.unmount(); + + const listRender = await renderMorse({ mode: null, onSelectMode: vi.fn() }, { route: '/post/morse?ref=list' }); + expect(listRender.container.querySelector('.xl\\:grid-cols-\\[minmax\\(0\\,1fr\\)_24rem\\]')).toBeTruthy(); + }); }); describe('isNodeOnPath (live keying highlight gate)', () => { diff --git a/client/src/components/messages/BeeperTab.jsx b/client/src/components/messages/BeeperTab.jsx new file mode 100644 index 0000000000..92316d0b48 --- /dev/null +++ b/client/src/components/messages/BeeperTab.jsx @@ -0,0 +1,173 @@ +import { + useCallback, useEffect, useRef, useState, +} from 'react'; +import { useParams, useSearchParams } from 'react-router'; +import toast from '../ui/Toast'; +import Drawer from '../Drawer'; +import useDrawerTab from '../../hooks/useDrawerTab'; +import useBeeperRealtime from '../../hooks/useBeeperRealtime'; +import useMounted from '../../hooks/useMounted'; +import { getBeeperStatus } from '../../services/api'; +import BeeperChatSurface from './beeper/BeeperChatSurface'; +import BeeperSettingsPanel from './beeper/BeeperSettingsPanel'; + +/** + * Comms → Messages → Beeper. The page shell for the chat surface (#35). + * + * It owns exactly two things the surface and the settings panel must share: + * + * 1. **The page-level realtime subscription.** `useBeeperRealtime` pairs its + * own `beeper:subscribe`/`beeper:unsubscribe` per mount, and several + * subscribers may be live at once — `useBeeperOutbox`, reached through + * `BeeperChatSurface`, mounts its own instance to refetch the outbox on a + * `message.upserted` invalidation (`client/src/hooks/README.md` sanctions + * this). This is the ONE that owns the status card: its liveness snapshot + * plus an invalidation counter (and the frames behind it, for + * `BeeperChatSurface`'s own frame-scoped thread refetch) are handed down + * as props, so the settings drawer never needs a subscription of its own. + * Sweep state (`sweep`) and `tokenConfigured` ride the same status fetch + * (#80) — no second poller, and every invalidation frame the sweep itself + * fires as it goes (see `beeperSync.js`) refreshes it here too. + * 2. **The settings drawer.** #30's status card is not removed by the chat + * surface landing — it moves behind a header action, deep-linked as + * `?settings=1` exactly like the iMessage ingestion drawer, so ⌘K and voice + * can open it and an actionable fault still has a home that is not a + * global banner. + * + * The open conversation is the route param on `/messages/beeper/:conversationId` + * (Messages routes it as the shared `:chatKey` segment), never local state. + */ + +// The OAuth 2.0 error codes Beeper's own consent screen can send back, +// mapped to a plain sentence rather than shown raw — the bare code read as +// implementation detail with no remedy. An unrecognized code still gets a +// generic sentence rather than disappearing, with the raw code kept as a +// trailing parenthetical either way so the exact server-reported reason is +// never lost, only never led with. +const OAUTH_ERROR_SENTENCES = { + access_denied: 'Beeper connect was not approved', + invalid_scope: 'Beeper could not grant the access PortOS asked for', + server_error: 'Beeper reported a server error during connect', +}; +const oauthErrorSentence = (code) => `${OAUTH_ERROR_SENTENCES[code] || 'Beeper connect failed'} (${code})`; + +export default function BeeperTab() { + const { chatKey } = useParams(); + const [settingsParam, setSettingsParam] = useDrawerTab('settings', null, ['1']); + // `invalidationSeq` is the "something changed, re-read the mirror" pulse — + // still a bare counter, because the list/networks refetch it drives (design + // decision: other chats' previews and unread counts always refresh) needs no + // frame detail. `invalidationFramesRef` rides alongside it as a MAILBOX, not + // React state: `BeeperChatSurface` needs each frame's own `chatID` to decide + // whether the OPEN THREAD is in scope for a refetch (audit cluster 07, + // findings PERF-6/BEEP-5) — a counter alone cannot tell "another chat + // changed" from "this one did". It is a ref rather than state because the + // surface drains it itself once it has scheduled a refetch for everything + // currently in it, so relaying frames down costs no extra render and needs + // no hand-back of "how many did you consume". + const invalidationFramesRef = useRef([]); + const [invalidationSeq, setInvalidationSeq] = useState(0); + + const mountedRef = useMounted(); + const onInvalidate = useCallback((frame) => { + invalidationFramesRef.current.push(frame ?? null); + setInvalidationSeq((seq) => seq + 1); + }, []); + const { realtime, seedRealtime } = useBeeperRealtime({ onInvalidate }); + + // The outbound runaway breaker's read model (#36, decided on #8). The + // composer disables Send off this — the SAME status the settings drawer's + // `BeeperOutboxBreakerBanner` already reads — rather than a second banner on + // the chat surface, which #12 decision 4 reserves for the settings card. + const [breaker, setBreaker] = useState(null); + + // Sweep visibility (#80): running/idle, started/finished, accounts done of + // the total, chats and messages mirrored so far — the list header's + // "Syncing… N of M accounts" / "Last synced HH:MM" strip reads this, and + // `tokenConfigured` is what the empty state branches on instead of + // `networks.length` alone. Both ride the SAME status fetch as `realtime` + // and `breaker` below, reusing the existing mount + invalidation-frame + // triggers rather than adding a second poller — a sweep's own per-account + // progress reaches here because `beeperSync.js` fires an invalidation frame + // as it goes (see beeperSync.js's `emitSweepInvalidation`). + const [sweep, setSweep] = useState(null); + const [tokenConfigured, setTokenConfigured] = useState(false); + + // Seeded from the page, not from the settings drawer: `beeper:subscribe` + // does not push the current transport state, and the drawer's own status + // fetch only runs once it is opened — so without this the rail's dot would + // stay blank on a healthy install until something changed. The breaker flag + // rides the same fetch for the same reason: the composer needs it before the + // user has ever opened the settings drawer. + const seedStatus = useCallback(() => { + getBeeperStatus({ silent: true }) + .then((status) => { + if (!mountedRef.current) return; + if (status?.realtime) seedRealtime(status.realtime); + setBreaker(status?.outbox?.breaker || null); + setSweep(status?.sweep || null); + setTokenConfigured(status?.tokenConfigured === true); + }) + .catch(() => {}); + }, [seedRealtime, mountedRef]); + + // Re-read on the invalidation counter, not only at mount. `seedStatus` is a + // stable callback, so keying the effect on it alone made this a MOUNT-TIME + // SNAPSHOT: a breaker that trips during the session (a send loop, three + // refused sends in a row) never reached the composer, which stayed live + // against a server that would refuse every send until a human cleared it — + // and the only way to see the truth was a page reload. The counter is the + // one "something moved" signal this page already owns. + useEffect(() => { seedStatus(); }, [seedStatus, invalidationSeq]); + + // Beeper redirects the BROWSER back to this PAGE after consent (#31), not to + // the settings drawer — so the outcome flag is read here, where something is + // always mounted, rather than in the panel that only exists while the drawer + // is open. The server callback already exchanged the code and vaulted the + // token; all that arrives is the outcome. Report it once, then strip it so a + // reload doesn't repeat the toast, and on a FAILURE open the settings drawer + // in the same URL write, because that is where the connect card that fixes it + // lives. + const [searchParams, setSearchParams] = useSearchParams(); + const oauthConnected = searchParams.get('beeperConnected'); + const oauthError = searchParams.get('beeperOauthError'); + useEffect(() => { + if (!oauthConnected && !oauthError) return; + if (oauthError) toast.error(oauthErrorSentence(oauthError)); + else { + toast.success('Beeper connected'); + seedStatus(); + } + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.delete('beeperConnected'); + next.delete('beeperOauthError'); + if (oauthError) next.set('settings', '1'); + return next; + }, { replace: true }); + }, [oauthConnected, oauthError, setSearchParams, seedStatus]); + + return ( +

+ setSettingsParam('1')} + /> + + setSettingsParam(null)} + title="Beeper Settings" + size="md" + > + + +
+ ); +} diff --git a/client/src/components/messages/BeeperTab.test.jsx b/client/src/components/messages/BeeperTab.test.jsx new file mode 100644 index 0000000000..e4b240529d --- /dev/null +++ b/client/src/components/messages/BeeperTab.test.jsx @@ -0,0 +1,1598 @@ +import { + afterEach, beforeEach, describe, expect, it, vi, +} from 'vitest'; +import { + act, cleanup, fireEvent, render, screen, waitFor, within, +} from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router'; + +/** + * The Beeper chat surface (#35), tested at the page boundary — the same seam a + * user reaches it through, so the rail, the list, the thread, the URL contract + * and the realtime wiring are exercised together rather than one prop at a time. + * + * EVERY fixture value below is invented: placeholder names, `example.com` + * handles and 555-01xx numbers per root AGENTS.md Sensitive Data & Privacy. The + * last test in this file is a guard that keeps it that way — no value from a + * running instance may ever be pasted in here. + */ + +const api = vi.hoisted(() => ({ + getBeeperStatus: vi.fn(), + syncBeeperNow: vi.fn(), + getBeeperNetworks: vi.fn(), + getBeeperConversations: vi.fn(), + getBeeperConversation: vi.fn(), + getBeeperMessages: vi.fn(), + setBeeperConversationArchived: vi.fn(), + setBeeperConversationLowPriority: vi.fn(), + // The LOCAL "seen in PortOS" watermark (#83) — never a Beeper write. + markBeeperConversationSeen: vi.fn(), + linkBeeperParticipant: vi.fn(), + createTribePersonFromBeeper: vi.fn(), + unlinkBeeperParticipant: vi.fn(), + getTribePeople: vi.fn(), + // Reached through the settings drawer. Declared here because + // `BeeperSettingsPanel` imports them at module load, whether or not a test + // opens the drawer — a named import missing from the mock throws. + getSettings: vi.fn(), + updateSettings: vi.fn(), + checkBeeperConnection: vi.fn(), + startBeeperOAuth: vi.fn(), + saveBeeperToken: vi.fn(), + disconnectBeeper: vi.fn(), + // Attachment byte mirror (#37). `beeperAttachmentUrl` is called during + // render, so it must return a string rather than a mock's `undefined`. + beeperAttachmentUrl: vi.fn((messageId, idx) => `/api/beeper/attachments/${messageId}/${idx}`), + fetchBeeperAttachment: vi.fn(), + setBeeperAttachmentKeep: vi.fn(), + getBeeperAttachmentSummary: vi.fn(), + backfillBeeperAttachments: vi.fn(), + purgeBeeperConversation: vi.fn(), +})); +// The composer's send lifecycle (#36) reaches the server through +// `apiBeeper.js` directly — a DIFFERENT module specifier than the `api.js` +// barrel every other call in this file goes through — so `useBeeperOutbox` +// needs its own mock rather than riding along on `api` above. +const apiBeeper = vi.hoisted(() => ({ + listOutboxEntries: vi.fn(), + createOutboxEntry: vi.fn(), + sendOutboxEntry: vi.fn(), + discardOutboxEntry: vi.fn(), +})); +const toast = vi.hoisted(() => Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() })); +const socketMock = vi.hoisted(() => { + const handlers = new Map(); + const emitted = []; + return { + handlers, + emitted, + socket: { + on: (event, fn) => { + if (!handlers.has(event)) handlers.set(event, new Set()); + handlers.get(event).add(fn); + }, + off: (event, fn) => { handlers.get(event)?.delete(fn); }, + emit: (event, payload) => { emitted.push([event, payload]); }, + }, + }; +}); + +vi.mock('../../services/api', () => api); +vi.mock('../../services/apiBeeper', () => apiBeeper); +vi.mock('../ui/Toast', () => ({ default: toast })); +vi.mock('../../services/socket', () => ({ default: socketMock.socket })); + +const BeeperTab = (await import('./BeeperTab')).default; + +const CONV_A = '11111111-1111-4111-8111-111111111111'; +const CONV_B = '22222222-2222-4222-8222-222222222222'; + +// Nine networks, matching the outlier install #9 warns the design must not be +// tuned for. Ids only — the surface renders whatever the mirror reports. +const NINE_NETWORKS = [ + 'whatsapp', 'telegram', 'discord', 'signal', 'instagram', 'slack', 'x', 'facebook', 'googlemessages', +].map((network, index) => ({ network, conversationCount: 1, unreadCount: index, unreadConversations: 1, accountIds: [`acct-example-${index}`], lastActivity: '2026-09-01T10:00:00.000Z' })); + +const conversation = (overrides = {}) => ({ + id: CONV_A, + accountId: 'acct-example-0', + network: 'examplenet', + sourceChatId: 'chat-example-1', + title: 'Example Conversation', + type: 'single', + isGroup: false, + isPinned: false, + isArchived: false, + isLowPriority: false, + isMuted: false, + lastActivity: '2026-09-01T10:00:00.000Z', + unreadCount: 0, + lastMessage: null, + participants: [], + hasMoreParticipants: false, + ...overrides, +}); + +const renderTab = (initialPath = '/messages/beeper') => render( + + + } /> + } /> + + , +); + +beforeEach(() => { + vi.clearAllMocks(); + socketMock.handlers.clear(); + socketMock.emitted.length = 0; + api.getBeeperStatus.mockResolvedValue({ tokenConfigured: true, reachable: true, accounts: [], realtime: { state: 'connected' } }); + api.syncBeeperNow.mockResolvedValue({ skipped: false, accounts: 0, chats: 0, messages: 0 }); + api.getBeeperNetworks.mockResolvedValue({ networks: [] }); + api.getBeeperConversations.mockResolvedValue({ conversations: [], nextCursor: null }); + api.getBeeperConversation.mockResolvedValue(conversation()); + api.getBeeperMessages.mockResolvedValue({ messages: [], nextCursor: null }); + api.markBeeperConversationSeen.mockResolvedValue(conversation()); + api.getTribePeople.mockResolvedValue([]); + apiBeeper.listOutboxEntries.mockResolvedValue({ entries: [] }); + apiBeeper.discardOutboxEntry.mockResolvedValue(undefined); + api.getSettings.mockResolvedValue({ beeper: { enabled: false, intervalMinutes: 5, baseUrl: 'http://127.0.0.1:23373', attachmentBudgetGb: 5 } }); + api.getBeeperAttachmentSummary.mockResolvedValue({ + budgetBytes: 5 * 1024 * 1024 * 1024, usedBytes: 0, storedFiles: 0, pendingCount: 0, pendingBytes: 0, + pendingUnknownCount: 0, overCapCount: 0, unavailableCount: 0, keptCount: 0, totalCount: 0, maxBytes: 32 * 1024 * 1024, + }); +}); + +afterEach(cleanup); + +describe('deep linking', () => { + it('opens the conversation directly on a cold load of its URL', async () => { + api.getBeeperNetworks.mockResolvedValue({ networks: [NINE_NETWORKS[0]] }); + api.getBeeperConversations.mockResolvedValue({ conversations: [conversation()], nextCursor: null }); + api.getBeeperConversation.mockResolvedValue(conversation({ title: 'Example Deep Link' })); + api.getBeeperMessages.mockResolvedValue({ + messages: [{ id: 'm1', conversationId: CONV_A, senderId: 'user-1', body: 'Placeholder message body', sentAt: '2026-09-01T10:00:00.000Z', attachments: [] }], + nextCursor: null, + }); + + renderTab(`/messages/beeper/${CONV_A}`); + + expect(await screen.findByText('Placeholder message body')).toBeInTheDocument(); + expect(api.getBeeperConversation).toHaveBeenCalledWith(CONV_A, { silent: true }); + // The list is fetched for the same scope, so the surface is whole rather + // than a bare thread with no way back. + expect(api.getBeeperConversations).toHaveBeenCalled(); + }); + + it('renders a not-found state for a stale conversation id instead of an empty thread', async () => { + api.getBeeperConversation.mockRejectedValue(Object.assign(new Error('Conversation not found'), { status: 404 })); + renderTab(`/messages/beeper/${CONV_B}`); + expect(await screen.findByText('Conversation not found')).toBeInTheDocument(); + }); + + // The regression the reviewer found: an `apiCore` failure with no `.status` + // (503 unreachable, 500, offline) leaves the detail null and the error set, + // and the thread's "Pick a conversation" early return used to sit AHEAD of + // the error branch — so a URL that names a conversation rendered as if + // nothing were selected. Every fetch behind it is `{ silent: true }`, so + // there was no toast either: the failure was completely invisible. + it('renders the thread error with a Retry when a deep link fails, never "Pick a conversation"', async () => { + api.getBeeperConversation.mockRejectedValue(new Error('Beeper request failed: connection refused')); + api.getBeeperMessages.mockRejectedValue(new Error('Beeper request failed: connection refused')); + renderTab(`/messages/beeper/${CONV_B}`); + + expect(await screen.findByText('Could not open this conversation')).toBeInTheDocument(); + expect(screen.getByText('Beeper request failed: connection refused')).toBeInTheDocument(); + expect(screen.queryByText('Pick a conversation')).toBeNull(); + expect(screen.queryByText('Conversation not found')).toBeNull(); + + // Retry re-reads the mirror rather than leaving the pane stuck. + const calls = api.getBeeperConversation.mock.calls.length; + fireEvent.click(screen.getByRole('button', { name: /Retry/ })); + await waitFor(() => expect(api.getBeeperConversation.mock.calls.length).toBeGreaterThan(calls)); + }); + + it('scopes the list from the URL, so a shared link reopens the same scope', async () => { + renderTab('/messages/beeper?scope=net:whatsapp&unread=1'); + await waitFor(() => expect(api.getBeeperConversations).toHaveBeenCalledWith( + { unreadOnly: true, network: 'whatsapp', archived: false }, + { silent: true }, + )); + }); +}); + +describe('rendering at every install size', () => { + it('renders with zero conversations, and says an empty list is often correct when networks are mirrored and the list loaded fine', async () => { + api.getBeeperNetworks.mockResolvedValue({ networks: [NINE_NETWORKS[0]] }); + renderTab(); + expect(await screen.findByText('Nothing here')).toBeInTheDocument(); + expect(screen.getByText(/often correct rather than broken/)).toBeInTheDocument(); + expect(screen.queryByTestId('network-badge')).toBeNull(); + }); + + it('drops the per-row network badge inside a single-network scope', async () => { + api.getBeeperNetworks.mockResolvedValue({ networks: [NINE_NETWORKS[0]] }); + api.getBeeperConversations.mockResolvedValue({ + conversations: [conversation({ network: 'whatsapp' })], + nextCursor: null, + }); + + renderTab('/messages/beeper?scope=net:whatsapp'); + + expect(await screen.findByText('Example Conversation')).toBeInTheDocument(); + // The rail already states the network, so the badge would be noise — the + // one conditional rule #9 says a from-scratch design would have got wrong. + expect(screen.queryByTestId('network-badge')).toBeNull(); + }); + + it('badges every row in the unified inbox, at nine networks', async () => { + api.getBeeperNetworks.mockResolvedValue({ networks: NINE_NETWORKS }); + api.getBeeperConversations.mockResolvedValue({ + conversations: NINE_NETWORKS.map((entry, index) => conversation({ + id: `4444444${index}-4444-4444-8444-444444444444`, + network: entry.network, + title: `Example Conversation ${index}`, + })), + nextCursor: null, + }); + + renderTab(); + + expect(await screen.findByText('Example Conversation 0')).toBeInTheDocument(); + expect(screen.getAllByTestId('network-badge')).toHaveLength(9); + // One rail entry per mirrored network, and no hardcoded roster anywhere. + expect(screen.getByRole('button', { name: 'WhatsApp' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Google Messages' })).toBeInTheDocument(); + }); + + it('renders a network the client has no logo for rather than dropping it', async () => { + api.getBeeperNetworks.mockResolvedValue({ networks: [{ network: 'somenewbridge', unreadCount: 0, conversationCount: 1, accountIds: [] }] }); + renderTab(); + expect(await screen.findByRole('button', { name: 'somenewbridge' })).toBeInTheDocument(); + }); +}); + +/** + * Audit cluster 08 (A11Y-3): the empty conversation list always stated + * "{networks.length} network(s) mirrored ... often correct rather than + * broken", even when the networks fetch had failed (networks lands at [] + * either way) — rendering "0 networks mirrored" as settled fact directly + * under the visible error banner it contradicted. The fix branches the + * second paragraph on whether anything is actually known to be wrong or + * filtered, rather than always reciting the same reassurance. + */ +describe('the honest empty state (A11Y-3)', () => { + it('shows no networks-mirrored reassurance and offers the settings link when no networks are mirrored and Beeper is not connected', async () => { + // #80: the empty state branches on `tokenConfigured`, not on + // `networks.length` alone — the settings-link copy this test pins is the + // NOT-CONNECTED branch, so it needs a not-connected status. + api.getBeeperStatus.mockResolvedValue({ tokenConfigured: false, reachable: null, accounts: [], realtime: null }); + api.getBeeperNetworks.mockRejectedValue(new Error('network fetch failed')); + renderTab(); + await screen.findByText('Nothing here'); + expect(screen.queryByText(/network.*mirrored/i)).toBeNull(); + expect(screen.getByRole('button', { name: /open beeper settings/i })).toBeInTheDocument(); + }); + + it('names the unread filter instead of the reassurance when unreadOnly is on', async () => { + api.getBeeperNetworks.mockResolvedValue({ networks: [NINE_NETWORKS[0]] }); + renderTab('/messages/beeper?unread=1'); + await screen.findByText('Nothing here'); + expect(screen.getByText(/filter is on/i)).toBeInTheDocument(); + expect(screen.queryByText(/often correct rather than broken/)).toBeNull(); + }); + + it('suppresses the reassurance entirely when the conversation list itself failed to load', async () => { + api.getBeeperNetworks.mockResolvedValue({ networks: [NINE_NETWORKS[0]] }); + api.getBeeperConversations.mockRejectedValue(new Error('Could not load conversations')); + renderTab(); + await screen.findByText('Nothing here'); + expect(screen.queryByText(/network.*mirrored/i)).toBeNull(); + expect(screen.queryByText(/often correct rather than broken/)).toBeNull(); + expect(screen.queryByText(/nothing is mirrored yet/i)).toBeNull(); + expect(screen.queryByRole('button', { name: /open beeper settings/i })).toBeNull(); + }); + + // The other half of #80's branch: connected but nothing mirrored yet is a + // DIFFERENT state from never-connected, and used to render the same + // "connect a network" prompt to a user who already had. + it('says a first sync is in progress, with no settings link, when Beeper is connected but nothing is mirrored yet', async () => { + api.getBeeperStatus.mockResolvedValue({ tokenConfigured: true, reachable: true, accounts: [], realtime: null }); + api.getBeeperNetworks.mockResolvedValue({ networks: [] }); + renderTab(); + await screen.findByText('Nothing here'); + expect(screen.getByText(/first sync in progress/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /open beeper settings/i })).toBeNull(); + }); +}); + +/** + * "Sync now" (#79): the list-header refresh action used to call only + * `loadList()`/`loadNetworks()` — both pure SELECTs against the mirror — so + * clicking it never actually triggered a sweep. It now runs one sweep first. + */ +describe('Sync now', () => { + it('runs a sweep, then refetches the list and the networks', async () => { + api.getBeeperNetworks.mockResolvedValue({ networks: [NINE_NETWORKS[0]] }); + renderTab(); + await screen.findByText('Nothing here'); + api.getBeeperConversations.mockClear(); + api.getBeeperNetworks.mockClear(); + + fireEvent.click(screen.getByRole('button', { name: /sync now/i })); + + await waitFor(() => expect(api.syncBeeperNow).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(api.getBeeperConversations).toHaveBeenCalled()); + expect(api.getBeeperNetworks).toHaveBeenCalled(); + // The sweep must land BEFORE the refetch, or the refetch could race a + // sweep that has not written anything yet. + const [syncOrder] = api.syncBeeperNow.mock.invocationCallOrder; + const [listOrder] = api.getBeeperConversations.mock.invocationCallOrder; + expect(syncOrder).toBeLessThan(listOrder); + }); + + it('still refetches, without an error toast, when the sweep reports skipped: true', async () => { + api.syncBeeperNow.mockResolvedValue({ skipped: true }); + renderTab(); + await screen.findByText('Nothing here'); + api.getBeeperConversations.mockClear(); + + fireEvent.click(screen.getByRole('button', { name: /sync now/i })); + + await waitFor(() => expect(api.getBeeperConversations).toHaveBeenCalled()); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('toasts and still re-enables the button when the sweep request itself fails', async () => { + api.syncBeeperNow.mockRejectedValue(new Error('Beeper sweep failed for all 3 accounts')); + renderTab(); + await screen.findByText('Nothing here'); + + const button = screen.getByRole('button', { name: /sync now/i }); + fireEvent.click(button); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Beeper sweep failed for all 3 accounts')); + expect(button).toBeEnabled(); + }); +}); + +/** + * The list header's sweep-visibility strip (#80): "Syncing… N of M accounts" + * while a sweep runs, "Last synced HH:MM" once idle. Both ride the same + * `GET /api/beeper/status` fetch `realtime`/`breaker` already used, seeded at + * mount — no separate polling loop. + */ +describe('the syncing / last-synced strip', () => { + it('shows "Syncing… N of M accounts" while a sweep is running', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, accounts: [], realtime: null, + sweep: { + running: true, startedAt: '2026-09-05T10:00:00.000Z', finishedAt: null, reason: 'scheduler', + accountsDone: 3, accountsTotal: 9, chats: 40, messages: 812, + }, + }); + renderTab(); + expect(await screen.findByText('Syncing… 3 of 9 accounts')).toBeInTheDocument(); + }); + + it('shows "Last synced HH:MM" once idle, formatted from finishedAt', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, accounts: [], realtime: null, + sweep: { + running: false, startedAt: '2026-09-05T10:00:00.000Z', finishedAt: '2026-09-05T10:04:00.000Z', reason: 'manual', + accountsDone: 9, accountsTotal: 9, chats: 210, messages: 4032, + }, + }); + renderTab(); + expect(await screen.findByText(/Last synced \d{1,2}:\d{2}/)).toBeInTheDocument(); + }); + + it('shows neither line before any sweep has ever run', async () => { + api.getBeeperStatus.mockResolvedValue({ tokenConfigured: true, reachable: true, accounts: [], realtime: null, sweep: null }); + renderTab(); + await screen.findByText('Nothing here'); + expect(screen.queryByText(/Syncing…/)).toBeNull(); + expect(screen.queryByText(/Last synced/)).toBeNull(); + }); +}); + +/** + * Audit cluster 08 (A11Y-6): the list-error banner carried no role, and + * `getBeeperConversations` is fetched silent, so a screen-reader user got no + * signal that the list had failed to load. + */ +describe('the conversation list error banner is announced', () => { + it('exposes the list-fetch failure with role="alert"', async () => { + api.getBeeperConversations.mockRejectedValue(new Error('Could not load conversations')); + renderTab(); + + expect(await screen.findByText('Could not load conversations')).toHaveAttribute('role', 'alert'); + }); +}); + +describe('message direction', () => { + it('puts own messages on the other side of the thread, from the mirrored isSender', async () => { + api.getBeeperConversation.mockResolvedValue(conversation({ + title: 'Example Contact', + participants: [{ sourceUserId: 'user-1', displayName: 'Sam Example', handle: '', tribePersonId: null, tribePersonName: null, observedVia: 'participant-list' }], + })); + api.getBeeperMessages.mockResolvedValue({ + messages: [ + { id: 'm2', conversationId: CONV_A, senderId: 'user-me', body: 'Placeholder outbound', sentAt: '2026-09-01T10:00:00.000Z', isSender: true, attachments: [] }, + { id: 'm1', conversationId: CONV_A, senderId: 'user-1', body: 'Placeholder inbound', sentAt: '2026-09-01T09:00:00.000Z', isSender: false, attachments: [] }, + ], + nextCursor: null, + }); + + renderTab(`/messages/beeper/${CONV_A}`); + + await screen.findByText('Placeholder outbound'); + const bubbles = screen.getAllByTestId('beeper-message'); + // Rendered oldest-first, so the inbound one comes first. + expect(bubbles.map((node) => node.dataset.direction)).toEqual(['in', 'out']); + // An own message carries no sender name — the reference's shape, and the + // reason direction has to be mirrored rather than guessed from senderId. + expect(within(bubbles[1]).queryByText('user-me')).toBeNull(); + expect(within(bubbles[0]).getByText('Sam Example')).toBeInTheDocument(); + }); + + it('renders a message with no direction as inbound rather than as unknown', async () => { + api.getBeeperMessages.mockResolvedValue({ + messages: [{ id: 'm1', conversationId: CONV_A, senderId: 'user-1', body: 'Placeholder body', sentAt: '2026-09-01T09:00:00.000Z', attachments: [] }], + nextCursor: null, + }); + renderTab(`/messages/beeper/${CONV_A}`); + await screen.findByText('Placeholder body'); + expect(screen.getByTestId('beeper-message').dataset.direction).toBe('in'); + }); +}); + +describe('the pinned grid is Beeper’s own isPinned, mirrored', () => { + it('lifts pinned conversations into the grid without any local pin state', async () => { + api.getBeeperConversations.mockResolvedValue({ + conversations: [ + conversation({ id: CONV_A, title: 'Example Pinned', isPinned: true }), + conversation({ id: CONV_B, title: 'Example Unpinned' }), + ], + nextCursor: null, + }); + renderTab(); + + await screen.findByText('Example Unpinned'); + // The grid renders the first word of a pinned title; the row list still + // holds both. Nothing in the client can pin — there is no such control. + expect(screen.getAllByText('Example').length).toBeGreaterThan(0); + expect(screen.queryByRole('button', { name: /^Pin/ })).toBeNull(); + }); +}); + +describe('deferred controls render inert rather than absent', () => { + it('disables Requests, Later, add-scope and the overflow menu, each saying it is not available yet', async () => { + renderTab(); + await screen.findByText('Nothing here'); + + for (const label of ['Requests', 'Later', 'Add scope', 'More scope options']) { + const control = screen.getByRole('button', { name: label }); + expect(control).toBeDisabled(); + expect(control).toHaveAttribute('title', `${label} — not available yet`); + } + }); + + it('keeps the two wired scopes live', async () => { + renderTab(); + await screen.findByText('Nothing here'); + expect(screen.getByRole('button', { name: 'Archive' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Low priority' })).toBeEnabled(); + }); +}); + +/** + * Audit cluster 08 (COPY-2): "not wired yet" is implementation vocabulary + * leaking into shipped copy — the InertControl tooltip template and the + * composer's attach button both used it. Upstream's own convention is + * "Coming soon — …" (ImportTab.jsx); this file's fix lands on + * "not available yet" / "aren't supported yet" instead. Scans the rendered + * DOM rather than one control at a time, so a leftover site anywhere would + * still fail this even if a future edit missed it. + */ +describe('no leftover "not wired" copy anywhere in the surface', () => { + it('renders no title or text containing "not wired", across the rail and the composer', async () => { + api.getBeeperConversation.mockResolvedValue(conversation({ title: 'Example Contact', network: 'whatsapp' })); + renderTab(`/messages/beeper/${CONV_A}`); + await screen.findByLabelText('Message Example Contact on WhatsApp'); + + const titled = [...document.querySelectorAll('[title]')].map((el) => el.getAttribute('title') || ''); + expect(titled.some((title) => title.toLowerCase().includes('not wired'))).toBe(false); + expect(document.body.textContent.toLowerCase()).not.toContain('not wired'); + }); +}); + +/** + * Audit cluster 08 (COPY-3): the scope heading rendered a ChevronDown inside + * a non-interactive span, implying a scope-picker menu that does not exist — + * against the file's own docstring ("an inert control that looks live is + * worse than an absent one"). The fix drops the chevron outright. + */ +describe('the scope heading has no control that looks interactive but has no handler', () => { + it('renders the scope label with no chevron or icon beside it', async () => { + renderTab(); + await screen.findByText('Nothing here'); + const label = screen.getByText('Inbox'); + expect(label.parentElement.querySelector('svg')).toBeNull(); + }); +}); + +describe('the composer', () => { + it('names the network it would send on and keeps the draft buffer', async () => { + api.getBeeperConversation.mockResolvedValue(conversation({ network: 'whatsapp', title: 'Example Contact' })); + renderTab(`/messages/beeper/${CONV_A}`); + + const composer = await screen.findByLabelText('Message Example Contact on WhatsApp'); + expect(composer).toHaveAttribute('placeholder', 'Message Example Contact on WhatsApp'); + }); + + it('disables Send while the draft is empty', async () => { + api.getBeeperConversation.mockResolvedValue(conversation({ network: 'whatsapp', title: 'Example Contact' })); + renderTab(`/messages/beeper/${CONV_A}`); + + const send = await screen.findByRole('button', { name: 'Send' }); + // aria-disabled (A11Y-5), not the native disabled attribute — the button + // must stay focusable so focus survives a mid-send disable. + expect(send).toHaveAttribute('aria-disabled', 'true'); + expect(send).not.toHaveAttribute('disabled'); + expect(send.getAttribute('title')).toBe('Type a message to send'); + }); +}); + +// The composer's send lifecycle (#53, wired on the durable outbox from #36). +// `useBeeperOutbox` has its own thorough unit coverage +// (`hooks/useBeeperOutbox.test.jsx`) — what matters HERE is that the composer +// wires the right props to it and renders what it reports, at the same page +// boundary the rest of this file tests through. +describe('the composer sends', () => { + const OUTBOUND_TEXT = 'Placeholder outbound text'; + + const openComposer = async (overrides = {}) => { + api.getBeeperConversation.mockResolvedValue(conversation({ network: 'whatsapp', title: 'Example Contact', ...overrides })); + renderTab(`/messages/beeper/${CONV_A}`); + const composer = await screen.findByLabelText('Message Example Contact on WhatsApp'); + fireEvent.change(composer, { target: { value: OUTBOUND_TEXT } }); + return composer; + }; + + it('enqueues a send through the outbox with the right payload — one row, one send call', async () => { + apiBeeper.createOutboxEntry.mockResolvedValue({ id: 'outbox-1', conversationId: CONV_A, body: OUTBOUND_TEXT, state: 'approved' }); + apiBeeper.sendOutboxEntry.mockResolvedValue({ id: 'outbox-1', conversationId: CONV_A, body: OUTBOUND_TEXT, state: 'awaiting-confirmation' }); + const composer = await openComposer(); + + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + + await waitFor(() => expect(apiBeeper.createOutboxEntry).toHaveBeenCalledWith(CONV_A, OUTBOUND_TEXT, { silent: true })); + expect(apiBeeper.createOutboxEntry).toHaveBeenCalledTimes(1); + await waitFor(() => expect(apiBeeper.sendOutboxEntry).toHaveBeenCalledWith('outbox-1', { confirmFirstContact: false }, { silent: true })); + expect(apiBeeper.sendOutboxEntry).toHaveBeenCalledTimes(1); + // `onSent` clears the draft buffer once the send is accepted. + await waitFor(() => expect(composer).toHaveValue('')); + }); + + it('renders the pending row while the send is in flight', async () => { + apiBeeper.createOutboxEntry.mockResolvedValue({ id: 'outbox-1', conversationId: CONV_A, body: OUTBOUND_TEXT, state: 'approved' }); + apiBeeper.sendOutboxEntry.mockReturnValue(new Promise(() => {})); // never settles within this test + await openComposer(); + + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + + const row = await screen.findByTestId('beeper-outbox-row'); + expect(row).toHaveAttribute('data-state', 'approved'); + expect(within(row).getByText(OUTBOUND_TEXT)).toBeInTheDocument(); + expect(within(row).getByText('Sending…')).toBeInTheDocument(); + }); + + it('shows the real mirrored message once an entry is confirmed, with no duplicate pending row', async () => { + apiBeeper.listOutboxEntries.mockResolvedValue({ + entries: [{ id: 'outbox-1', conversationId: CONV_A, body: OUTBOUND_TEXT, state: 'sent', messageId: 'msg-final-1' }], + }); + api.getBeeperMessages.mockResolvedValue({ + messages: [{ + id: 'msg-final-1', conversationId: CONV_A, senderId: 'user-me', body: OUTBOUND_TEXT, + sentAt: '2026-09-01T10:00:00.000Z', isSender: true, attachments: [], + }], + nextCursor: null, + }); + api.getBeeperConversation.mockResolvedValue(conversation({ network: 'whatsapp', title: 'Example Contact' })); + + renderTab(`/messages/beeper/${CONV_A}`); + + await screen.findByText(OUTBOUND_TEXT); + expect(screen.queryByTestId('beeper-outbox-row')).toBeNull(); + expect(screen.getAllByText(OUTBOUND_TEXT)).toHaveLength(1); + }); + + it('shows Retry on a failed send and leaves the composer text intact — never re-sent automatically', async () => { + apiBeeper.createOutboxEntry.mockResolvedValue({ id: 'outbox-1', conversationId: CONV_A, body: OUTBOUND_TEXT, state: 'approved' }); + apiBeeper.sendOutboxEntry.mockRejectedValue(Object.assign(new Error('Beeper request failed: connection refused'), { code: 'NETWORK_ERROR' })); + apiBeeper.listOutboxEntries.mockResolvedValue({ + entries: [{ id: 'outbox-1', conversationId: CONV_A, body: OUTBOUND_TEXT, state: 'failed', errorCode: 'NETWORK_ERROR' }], + }); + const composer = await openComposer(); + + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + + const row = await screen.findByTestId('beeper-outbox-row'); + await waitFor(() => expect(row).toHaveAttribute('data-state', 'failed')); + expect(within(row).getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + expect(apiBeeper.sendOutboxEntry).toHaveBeenCalledTimes(1); + expect(composer).toHaveValue(OUTBOUND_TEXT); + }); + + it('surfaces a first-contact refusal as an inline confirmation naming the network and recipient, then resends the SAME row', async () => { + apiBeeper.createOutboxEntry.mockResolvedValue({ id: 'outbox-1', conversationId: CONV_A, body: OUTBOUND_TEXT, state: 'approved' }); + apiBeeper.sendOutboxEntry + .mockRejectedValueOnce(Object.assign( + new Error('This is the first message PortOS has ever sent to this conversation'), + { code: 'FIRST_CONTACT_CONFIRMATION_REQUIRED' }, + )) + .mockResolvedValueOnce({ id: 'outbox-1', conversationId: CONV_A, body: OUTBOUND_TEXT, state: 'awaiting-confirmation' }); + await openComposer(); + + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + + // A question, not an error. + expect(await screen.findByText(/first message PortOS has sent to Example Contact on WhatsApp/)).toBeInTheDocument(); + expect(toast.error).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Send anyway' })); + + expect(apiBeeper.createOutboxEntry).toHaveBeenCalledTimes(1); + await waitFor(() => expect(apiBeeper.sendOutboxEntry).toHaveBeenLastCalledWith( + 'outbox-1', { confirmFirstContact: true }, { silent: true }, + )); + expect(apiBeeper.sendOutboxEntry).toHaveBeenCalledTimes(2); + await waitFor(() => expect(screen.queryByText(/send it\?/)).toBeNull()); + }); + + // The reviewer's blocker on #53: repro was open a conversation PortOS has + // never sent to, type, Send, then Cancel on the inline confirmation. The row + // used to stay `approved` and unrendered-as-cancelled, so it fell into + // OutboxRow's default branch and rendered a permanent "Sending…" bubble — + // reappearing on every reload because GET /outbox returns approved rows, with + // no dismiss control and no way back. Cancel must discard the row outright. + it('discards the pending bubble on Cancel — no phantom "Sending…" row left behind', async () => { + apiBeeper.createOutboxEntry.mockResolvedValue({ id: 'outbox-1', conversationId: CONV_A, body: OUTBOUND_TEXT, state: 'approved' }); + apiBeeper.sendOutboxEntry.mockRejectedValueOnce(Object.assign( + new Error('This is the first message PortOS has ever sent to this conversation'), + { code: 'FIRST_CONTACT_CONFIRMATION_REQUIRED' }, + )); + const composer = await openComposer(); + + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + expect(await screen.findByText(/first message PortOS has sent to Example Contact on WhatsApp/)).toBeInTheDocument(); + expect(await screen.findByTestId('beeper-outbox-row')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(apiBeeper.discardOutboxEntry).toHaveBeenCalledWith('outbox-1', { silent: true })); + // The question closes, the phantom bubble is gone, and the composer text is + // untouched — Cancel only withdraws the send, it does not clear the draft. + await waitFor(() => expect(screen.queryByText(/send it\?/)).toBeNull()); + expect(screen.queryByTestId('beeper-outbox-row')).toBeNull(); + expect(composer).toHaveValue(OUTBOUND_TEXT); + + // A GET /outbox after the cancel (e.g. a reload) no longer returns the row. + apiBeeper.listOutboxEntries.mockResolvedValue({ entries: [] }); + expect(apiBeeper.sendOutboxEntry).toHaveBeenCalledTimes(1); + }); + + it('submits once from ⌘/Ctrl+Enter and once from a click inside the same render — the hook latch', async () => { + apiBeeper.createOutboxEntry.mockResolvedValue({ id: 'outbox-1', conversationId: CONV_A, body: OUTBOUND_TEXT, state: 'approved' }); + apiBeeper.sendOutboxEntry.mockResolvedValue({ id: 'outbox-1', conversationId: CONV_A, body: OUTBOUND_TEXT, state: 'awaiting-confirmation' }); + const composer = await openComposer(); + const send = screen.getByRole('button', { name: 'Send' }); + + await act(async () => { + fireEvent.keyDown(composer, { key: 'Enter', metaKey: true }); + fireEvent.click(send); + await Promise.resolve(); + }); + + await waitFor(() => expect(apiBeeper.createOutboxEntry).toHaveBeenCalledTimes(1)); + expect(apiBeeper.sendOutboxEntry).toHaveBeenCalledTimes(1); + }); + + it('disables Send while the runaway breaker is tripped, even with a non-empty draft, and points at Settings', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, + reachable: true, + accounts: [], + realtime: { state: 'connected' }, + outbox: { breaker: { tripped: true, reason: 'synthetic loop', trippedAt: '2026-09-01T09:00:00.000Z' } }, + }); + const composer = await openComposer(); + + const send = screen.getByRole('button', { name: 'Send' }); + // aria-disabled (A11Y-5): the button stays focusable — clicking it is a + // no-op because handleSendClick itself checks canSend, not because the + // click never reaches the handler. + await waitFor(() => expect(send).toHaveAttribute('aria-disabled', 'true')); + expect(send.getAttribute('title')).toMatch(/runaway breaker/); + expect(composer).toHaveValue(OUTBOUND_TEXT); + + fireEvent.click(send); + expect(apiBeeper.createOutboxEntry).not.toHaveBeenCalled(); + }); + + // The breaker is PROCESS state that can trip mid-session — a send loop, or + // three refused sends in a row — and the composer's gate used to be a + // mount-time snapshot: `seedStatus` ran once and never again, so the Send + // button stayed live against a server that would refuse every send until a + // human cleared it, and only a page reload showed the truth. + it('disables Send for a breaker that trips after mount, with no page reload', async () => { + const status = (breaker) => ({ + tokenConfigured: true, reachable: true, accounts: [], realtime: { state: 'connected' }, outbox: { breaker }, + }); + api.getBeeperStatus.mockResolvedValue(status({ tripped: false, reason: null, trippedAt: null })); + await openComposer(); + + const send = screen.getByRole('button', { name: 'Send' }); + await waitFor(() => expect(send).toHaveAttribute('aria-disabled', 'false')); + + api.getBeeperStatus.mockResolvedValue(status({ tripped: true, reason: 'synthetic loop', trippedAt: '2026-09-01T09:00:00.000Z' })); + act(() => { + for (const fn of socketMock.handlers.get('beeper:invalidate') || []) { + fn({ kind: 'message.upserted', chatID: 'chat-example-1', ids: ['m1'], seq: 7 }); + } + }); + + await waitFor(() => expect(send).toHaveAttribute('aria-disabled', 'true')); + expect(send.getAttribute('title')).toMatch(/runaway breaker/); + expect(send.getAttribute('title')).toContain('synthetic loop'); + }); +}); + +describe('realtime', () => { + // TWO subscriptions on the same shared socket, not one: the page's own + // `useBeeperRealtime` (drives the rail's liveness dot and invalidation + // refetches) and the one `useBeeperOutbox` opens internally (#53, wired on + // #36's design) so the composer's send confirmation reacts to + // `message.upserted` without waiting on the page's debounced refetch. Both + // emit `beeper:subscribe` at mount and on every reconnect. Server-side this + // is a no-op redundancy, not a bug: `beeperSubscribers` is a `Set` keyed on + // the physical socket, so a second subscribe from the same socket dedupes, + // and both hook instances share BeeperChatSurface's mount lifecycle, so + // there is no unmount race where one instance's `beeper:unsubscribe` could + // kill the other's subscription out from under it. + it('subscribes on both realtime hooks and RE-SUBSCRIBES on every socket connect, so a reconnect is not silently dead', async () => { + renderTab(); + await screen.findByText('Nothing here'); + expect(socketMock.emitted.filter(([event]) => event === 'beeper:subscribe')).toHaveLength(2); + + act(() => { for (const fn of socketMock.handlers.get('connect') || []) fn(); }); + expect(socketMock.emitted.filter(([event]) => event === 'beeper:subscribe')).toHaveLength(4); + }); + + it('refetches the list and the open thread after an invalidation frame', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + renderTab(`/messages/beeper/${CONV_A}`); + // Settle every mount fetch inside act() before measuring — otherwise the + // refetch assertion races the initial load rather than the frame. + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + await act(async () => {}); + const listCalls = api.getBeeperConversations.mock.calls.length; + const threadCalls = api.getBeeperMessages.mock.calls.length; + + act(() => { + for (const fn of socketMock.handlers.get('beeper:invalidate') || []) { + fn({ kind: 'message.upserted', chatID: 'chat-example-1', ids: ['m1'], seq: 4 }); + } + }); + await act(async () => { await vi.advanceTimersByTimeAsync(400); }); + + expect(api.getBeeperConversations.mock.calls.length).toBeGreaterThan(listCalls); + expect(api.getBeeperMessages.mock.calls.length).toBeGreaterThan(threadCalls); + } finally { + vi.useRealTimers(); + } + }); + + // PERF-6/BEEP-5: the open thread used to refetch on ANY frame, discarding + // paged-in history for traffic in some other chat entirely — `onInvalidate` + // ignored the frame `useBeeperRealtime` already handed it. The list and + // networks still refetch unconditionally (their previews/unread counts DO + // change for any chat); only the thread refetch is now frame-scoped. + it('does not refetch the open thread on an invalidation frame for another chat', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + renderTab(`/messages/beeper/${CONV_A}`); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + await act(async () => {}); + const listCalls = api.getBeeperConversations.mock.calls.length; + const threadCalls = api.getBeeperMessages.mock.calls.length; + + act(() => { + for (const fn of socketMock.handlers.get('beeper:invalidate') || []) { + // CONV_A's fixture sourceChatId is 'chat-example-1' — this names a + // different chat entirely. + fn({ kind: 'message.upserted', chatID: 'chat-some-other-chat', ids: ['m9'], seq: 5 }); + } + }); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + + expect(api.getBeeperConversations.mock.calls.length).toBeGreaterThan(listCalls); + expect(api.getBeeperMessages.mock.calls.length).toBe(threadCalls); + } finally { + vi.useRealTimers(); + } + }); + + it('refetches the open thread on a frame naming its chat, or with no chatID at all', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + renderTab(`/messages/beeper/${CONV_A}`); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + await act(async () => {}); + + let threadCalls = api.getBeeperMessages.mock.calls.length; + act(() => { + for (const fn of socketMock.handlers.get('beeper:invalidate') || []) { + fn({ kind: 'message.upserted', chatID: 'chat-example-1', ids: ['m1'], seq: 6 }); + } + }); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + expect(api.getBeeperMessages.mock.calls.length).toBeGreaterThan(threadCalls); + + // A frame this vague (no chatID at all) could be about anything, so the + // safe read is "maybe this thread" rather than "not this thread". + threadCalls = api.getBeeperMessages.mock.calls.length; + act(() => { + for (const fn of socketMock.handlers.get('beeper:invalidate') || []) { + fn({ kind: 'chat.upserted', chatID: null, ids: [], seq: 7 }); + } + }); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + expect(api.getBeeperMessages.mock.calls.length).toBeGreaterThan(threadCalls); + } finally { + vi.useRealTimers(); + } + }); + + // PERF-6/BEEP-5, the additive half: a frame in scope must not replace the + // thread wholesale — that discards whatever "Load earlier messages" already + // paged in and resets the cursor. The fix fetches just the first page and + // merges it into the HEAD by id. + it('merges an in-scope invalidation refetch into the head, keeping paged-in history and the cursor', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const newest = { + id: 'm1', conversationId: CONV_A, senderId: 'user-1', body: 'Placeholder newest message', + sentAt: '2026-09-01T10:00:00.000Z', attachments: [], + }; + const older = { + id: 'm-older', conversationId: CONV_A, senderId: 'user-1', body: 'Placeholder older message', + sentAt: '2026-09-01T08:00:00.000Z', attachments: [], + }; + api.getBeeperMessages.mockResolvedValueOnce({ messages: [newest], nextCursor: 'cursor-1' }); + renderTab(`/messages/beeper/${CONV_A}`); + await screen.findByText('Placeholder newest message'); + + api.getBeeperMessages.mockResolvedValueOnce({ messages: [older], nextCursor: null }); + fireEvent.click(screen.getByRole('button', { name: 'Load earlier messages' })); + await screen.findByText('Placeholder older message'); + + // The refresh brings back an UPDATED copy of the same newest message — + // same id, new body — and nothing else new. + api.getBeeperMessages.mockResolvedValueOnce({ + messages: [{ ...newest, body: 'Placeholder newest message, edited' }], + nextCursor: 'should-be-ignored-by-a-head-only-merge', + }); + act(() => { + for (const fn of socketMock.handlers.get('beeper:invalidate') || []) { + fn({ kind: 'message.upserted', chatID: 'chat-example-1', ids: ['m1'], seq: 9 }); + } + }); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + + expect(await screen.findByText('Placeholder newest message, edited')).toBeInTheDocument(); + // Updated in place, not duplicated. + expect(screen.queryByText('Placeholder newest message')).toBeNull(); + // The paged-in older message survives the merge. + expect(screen.getByText('Placeholder older message')).toBeInTheDocument(); + // The cursor from the additive refresh is ignored — it was already + // nulled by the earlier "Load earlier messages" and stays that way. + expect(screen.queryByRole('button', { name: 'Load earlier messages' })).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + // PERF-10: the debounce had no maximum wait, so a sustained frame stream — + // each one arriving inside the previous frame's 350ms coalescing window — + // reset it forever and the view never refreshed at all. + it('still refetches within the bounded max wait despite a sustained stream of invalidation frames', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + renderTab(`/messages/beeper/${CONV_A}`); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + await act(async () => {}); + const listCalls = api.getBeeperConversations.mock.calls.length; + + // A frame every 200ms, well inside the 350ms debounce, for 2.4s total — + // past the 2s ceiling. + for (let i = 0; i < 12; i += 1) { + act(() => { + for (const fn of socketMock.handlers.get('beeper:invalidate') || []) { + fn({ kind: 'message.upserted', chatID: 'chat-example-1', ids: [`m${i}`], seq: i + 1 }); + } + }); + // eslint-disable-next-line no-await-in-loop + await act(async () => { await vi.advanceTimersByTimeAsync(200); }); + } + + expect(api.getBeeperConversations.mock.calls.length).toBeGreaterThan(listCalls); + } finally { + vi.useRealTimers(); + } + }); + + it('renders the liveness dot from the transport state and never as offline before it reports', async () => { + api.getBeeperStatus.mockResolvedValue({ tokenConfigured: true, reachable: true, accounts: [], realtime: null }); + renderTab(); + await screen.findByText('Nothing here'); + expect(screen.queryByTestId('connection-status-dot')).toBeNull(); + + act(() => { + for (const fn of socketMock.handlers.get('beeper:realtime') || []) fn({ state: 'connected' }); + }); + expect(await screen.findByTestId('connection-status-dot')).toHaveAttribute('data-status', 'connected'); + }); +}); + +describe('thread pagination', () => { + it('sends the cursor on the second call and renders both pages', async () => { + api.getBeeperMessages.mockResolvedValueOnce({ + messages: [{ id: 'm1', conversationId: CONV_A, senderId: 'user-1', body: 'Placeholder page one', sentAt: '2026-09-01T10:00:00.000Z', attachments: [] }], + nextCursor: 'cursor-1', + }); + renderTab(`/messages/beeper/${CONV_A}`); + await screen.findByText('Placeholder page one'); + + api.getBeeperMessages.mockResolvedValueOnce({ + messages: [{ id: 'm0', conversationId: CONV_A, senderId: 'user-1', body: 'Placeholder page two', sentAt: '2026-09-01T09:00:00.000Z', attachments: [] }], + nextCursor: null, + }); + fireEvent.click(screen.getByRole('button', { name: 'Load earlier messages' })); + + await screen.findByText('Placeholder page two'); + expect(api.getBeeperMessages).toHaveBeenLastCalledWith(CONV_A, { cursor: 'cursor-1' }, { silent: true }); + expect(screen.getByText('Placeholder page one')).toBeInTheDocument(); + // The cursor came back null, so there is nothing further to page in. + expect(screen.queryByRole('button', { name: 'Load earlier messages' })).toBeNull(); + }); + + // Finding T3: the thread-pagination generation guard used to return early + // on a stale response WITHOUT clearing `loadingMore`, so "Load earlier + // messages" rendered permanently disabled from that point on — even for a + // conversation switched to AFTER the stale request was issued. + it('leaves Load-more enabled on the new conversation when an old load-more resolves after a conversation switch', async () => { + api.getBeeperConversations.mockResolvedValue({ + conversations: [ + conversation({ id: CONV_A, title: 'Example A', sourceChatId: 'chat-example-1' }), + conversation({ id: CONV_B, title: 'Example B', sourceChatId: 'chat-example-2' }), + ], + nextCursor: null, + }); + api.getBeeperConversation.mockImplementation((id) => Promise.resolve( + id === CONV_B + ? conversation({ id: CONV_B, title: 'Example B', sourceChatId: 'chat-example-2' }) + : conversation({ id: CONV_A, title: 'Example A', sourceChatId: 'chat-example-1' }), + )); + + let releaseOldPage; + const oldPage = new Promise((resolve) => { releaseOldPage = resolve; }); + api.getBeeperMessages.mockImplementation((id, opts) => { + if (id === CONV_A && !opts?.cursor) { + return Promise.resolve({ + messages: [{ id: 'm1', conversationId: CONV_A, senderId: 'user-1', body: 'Placeholder A newest', sentAt: '2026-09-01T10:00:00.000Z', attachments: [] }], + nextCursor: 'cursor-a', + }); + } + if (id === CONV_A && opts?.cursor === 'cursor-a') return oldPage; + if (id === CONV_B) { + return Promise.resolve({ + messages: [{ id: 'm2', conversationId: CONV_B, senderId: 'user-1', body: 'Placeholder B newest', sentAt: '2026-09-01T09:00:00.000Z', attachments: [] }], + nextCursor: 'cursor-b', + }); + } + return Promise.resolve({ messages: [], nextCursor: null }); + }); + + renderTab(`/messages/beeper/${CONV_A}`); + await screen.findByText('Placeholder A newest'); + + fireEvent.click(screen.getByRole('button', { name: 'Load earlier messages' })); + await waitFor(() => expect(api.getBeeperMessages).toHaveBeenLastCalledWith(CONV_A, { cursor: 'cursor-a' }, { silent: true })); + + // Switch conversations while A's load-more is still in flight. + fireEvent.click(screen.getByText('Example B')); + await screen.findByText('Placeholder B newest'); + const loadMoreForB = await screen.findByRole('button', { name: 'Load earlier messages' }); + + releaseOldPage({ + messages: [{ id: 'm-old', conversationId: CONV_A, senderId: 'user-1', body: 'Placeholder A older', sentAt: '2026-09-01T08:00:00.000Z', attachments: [] }], + nextCursor: null, + }); + // The finally clause must clear `loadingMore` even though the generation + // guard drops the stale response itself. + await waitFor(() => expect(loadMoreForB).toBeEnabled()); + expect(screen.queryByText('Placeholder A older')).toBeNull(); + }); +}); + +describe('list pagination (Load more)', () => { + // Finding T5: `loadMoreConversations` had no in-flight latch — mirroring + // `submittingRef` in `useBeeperOutbox.js` — so two clicks landing inside one + // render both read "not loading yet" and both fired the same cursor page. + it('issues exactly one extra page request when Load more is clicked twice inside one act()', async () => { + api.getBeeperConversations + .mockResolvedValueOnce({ conversations: [conversation({ id: CONV_A, title: 'Example A' })], nextCursor: 'cursor-1' }) + .mockResolvedValueOnce({ conversations: [conversation({ id: CONV_B, title: 'Example B' })], nextCursor: null }); + + renderTab(); + await screen.findByText('Example A'); + const loadMore = screen.getByRole('button', { name: 'Load more' }); + + await act(async () => { + fireEvent.click(loadMore); + fireEvent.click(loadMore); + await Promise.resolve(); + }); + + await screen.findByText('Example B'); + // One mount-time load plus exactly one page fetch — not two. + expect(api.getBeeperConversations).toHaveBeenCalledTimes(2); + expect(screen.getAllByText('Example B')).toHaveLength(1); + }); +}); + +describe('the two wired rail controls', () => { + it('archives through the API and reflects the value the server returned', async () => { + api.getBeeperConversation.mockResolvedValue(conversation({ title: 'Example Contact' })); + api.setBeeperConversationArchived.mockResolvedValue(conversation({ title: 'Example Contact', isArchived: true })); + renderTab(`/messages/beeper/${CONV_A}`); + + const button = await screen.findByRole('button', { name: 'Archive', hidden: false }); + // Two controls carry the Archive name (the rail scope and the thread + // action); the thread action is the one inside the header row. + const threadArchive = screen.getAllByRole('button', { name: 'Archive' }).at(-1); + expect(button).toBeTruthy(); + act(() => { threadArchive.click(); }); + + await waitFor(() => expect(api.setBeeperConversationArchived).toHaveBeenCalledWith(CONV_A, true, { silent: true })); + expect(await screen.findByRole('button', { name: 'Unarchive' })).toBeInTheDocument(); + }); + + it('reports a failed write once and does not retry it', async () => { + api.getBeeperConversation.mockResolvedValue(conversation({ title: 'Example Contact' })); + api.setBeeperConversationLowPriority.mockRejectedValue( + Object.assign(new Error('Beeper request failed: connection refused'), { status: 503, context: { retryable: false } }), + ); + renderTab(`/messages/beeper/${CONV_A}`); + + const button = await screen.findByRole('button', { name: 'Low priority', hidden: false }); + const threadControl = screen.getAllByRole('button', { name: 'Low priority' }).at(-1); + expect(button).toBeTruthy(); + act(() => { threadControl.click(); }); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Beeper request failed: connection refused')); + expect(api.setBeeperConversationLowPriority).toHaveBeenCalledTimes(1); + }); +}); + +// The LOCAL "seen in PortOS" watermark (#83) — the fix for opening an unread +// thread leaving its badge untouched. This NEVER reaches Beeper: no PATCH, +// no read receipt — see `markBeeperConversationSeen` (`apiBeeper.js`) and its +// server-side `markConversationSeen`. +describe('the local "seen in PortOS" watermark (#83)', () => { + it('clears the row\'s own badge on open and calls the local mark-seen endpoint, never a Beeper write', async () => { + api.getBeeperConversations.mockResolvedValue({ + conversations: [conversation({ title: 'Example Unread', unreadCount: 3 })], + nextCursor: null, + }); + api.getBeeperConversation.mockResolvedValue(conversation({ title: 'Example Unread', unreadCount: 3 })); + + renderTab(); + + const row = (await screen.findByText('Example Unread')).closest('button'); + expect(within(row).getByText('3')).toBeInTheDocument(); + + fireEvent.click(row); + + await waitFor(() => expect(api.markBeeperConversationSeen).toHaveBeenCalledWith(CONV_A, { silent: true })); + // Optimistic: the row's own badge is gone as soon as the thread has + // loaded, without waiting on the mark-seen POST's own round trip. + await waitFor(() => expect(within(row).queryByText('3')).toBeNull()); + // Never a write to Beeper — this is a local read-model change only. + expect(api.setBeeperConversationArchived).not.toHaveBeenCalled(); + expect(api.setBeeperConversationLowPriority).not.toHaveBeenCalled(); + }); + + it('re-marks seen when a new message lands in the thread that is already open', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + renderTab(`/messages/beeper/${CONV_A}`); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + await act(async () => {}); + await waitFor(() => expect(api.markBeeperConversationSeen).toHaveBeenCalledWith(CONV_A, { silent: true })); + api.markBeeperConversationSeen.mockClear(); + + api.getBeeperMessages.mockResolvedValueOnce({ + messages: [{ + id: 'm-new', conversationId: CONV_A, senderId: 'user-1', body: 'Placeholder new message', + sentAt: '2026-09-01T11:00:00.000Z', attachments: [], + }], + nextCursor: null, + }); + act(() => { + for (const fn of socketMock.handlers.get('beeper:invalidate') || []) { + fn({ kind: 'message.upserted', chatID: 'chat-example-1', ids: ['m-new'], seq: 10 }); + } + }); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + + await waitFor(() => expect(api.markBeeperConversationSeen).toHaveBeenCalledWith(CONV_A, { silent: true })); + } finally { + vi.useRealTimers(); + } + }); + + // PR 86's sweep emits an invalidation frame with `chatID: null` once per + // account, which this surface's debounce treats as "could be about + // anything" and refetches the open thread regardless — so a refetch that + // comes back with nothing genuinely new (an UPDATED copy of an + // already-rendered message, e.g. an edit or the eventual `message.upserted` + // confirmation of a message already on screen) must not re-POST mark-seen. + // Without this a multi-account sweep would fire one no-op mark-seen call + // per account for a thread with nothing new in it. + it('does not re-mark seen when an invalidation refetch returns only already-known messages', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const known = { + id: 'm1', conversationId: CONV_A, senderId: 'user-1', body: 'Placeholder known message', + sentAt: '2026-09-01T10:00:00.000Z', attachments: [], + }; + api.getBeeperMessages.mockResolvedValue({ messages: [known], nextCursor: null }); + + renderTab(`/messages/beeper/${CONV_A}`); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + await act(async () => {}); + await waitFor(() => expect(api.markBeeperConversationSeen).toHaveBeenCalledWith(CONV_A, { silent: true })); + api.markBeeperConversationSeen.mockClear(); + + // Same id back, just an updated body — not new activity. + api.getBeeperMessages.mockResolvedValueOnce({ + messages: [{ ...known, body: 'Placeholder known message, edited' }], + nextCursor: null, + }); + act(() => { + for (const fn of socketMock.handlers.get('beeper:invalidate') || []) { + fn({ kind: 'chat.upserted', chatID: null, ids: [], seq: 11 }); + } + }); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + + expect(api.markBeeperConversationSeen).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('does not let a failed mark-seen call break opening the thread', async () => { + api.markBeeperConversationSeen.mockRejectedValue(new Error('offline')); + api.getBeeperConversation.mockResolvedValue(conversation({ title: 'Example Contact' })); + renderTab(`/messages/beeper/${CONV_A}`); + expect(await screen.findByText('Example Contact')).toBeInTheDocument(); + }); +}); + +describe('the inline Tribe link action', () => { + it('links a participant to an existing Tribe person', async () => { + api.getTribePeople.mockResolvedValue([{ id: '55555555-5555-4555-8555-555555555555', name: 'Alex Example' }]); + api.getBeeperConversation.mockResolvedValue(conversation({ + title: 'Example Contact', + participants: [{ sourceUserId: 'user-1', displayName: 'Sam Example', handle: '+15550100', tribePersonId: null, tribePersonName: null, observedVia: 'participant-list' }], + })); + api.linkBeeperParticipant.mockResolvedValue({ participant: {}, displacedPersonId: null }); + + renderTab(`/messages/beeper/${CONV_A}`); + + const peopleToggle = await screen.findByRole('button', { name: 'People' }); + act(() => { peopleToggle.click(); }); + // The search-first picker (#98 part B) replaced the `` from, so a person + * created here looks identical to one created on the Tribe page rather than + * inventing a second, driftable copy of the ring options. + */ +export default function BeeperCreatePersonForm({ + participant, onCreate, onCancel, disabled = false, +}) { + const initialName = participant.displayName || participant.handle || ''; + const [name, setName] = useState(initialName); + const [ring, setRing] = useState('tribe'); + const [relationship, setRelationship] = useState(''); + // Native `autoFocus` (below) fires once, on this element's own mount — the + // form is a fresh subtree every time the row switches into 'create' mode, + // so there is no need for a ref+effect dance to refocus on every render. + const nameRef = useRef(null); + + const trimmedName = name.trim(); + const canCreate = trimmedName.length > 0 && !disabled; + + const submit = () => { + if (!canCreate) return; + onCreate({ name: trimmedName, ring, relationship: relationship.trim() }); + }; + + // Enter/Escape are handled once on the wrapping group rather than on each + // field individually — bubbling keydown covers the name field, the ring + // select and the relationship field alike, so Enter submits and Escape + // cancels no matter which of the three currently has focus. + const handleKeyDown = (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + submit(); + } else if (event.key === 'Escape') { + event.preventDefault(); + onCancel(); + } + }; + + return ( +
+ setName(event.target.value)} + disabled={disabled} + autoFocus + required + placeholder="Name" + aria-label="Name" + className="min-w-0 flex-1 rounded border border-port-border bg-port-bg px-1.5 py-1 text-[11px] text-gray-200 placeholder:text-gray-500 focus:outline-none disabled:opacity-50" + /> + + setRelationship(event.target.value)} + disabled={disabled} + placeholder="Relationship (optional)" + aria-label="Relationship" + className="min-w-0 flex-1 rounded border border-port-border bg-port-bg px-1.5 py-1 text-[11px] text-gray-200 placeholder:text-gray-500 focus:outline-none disabled:opacity-50" + /> + + +
+ ); +} diff --git a/client/src/components/messages/beeper/BeeperCreatePersonForm.test.jsx b/client/src/components/messages/beeper/BeeperCreatePersonForm.test.jsx new file mode 100644 index 0000000000..e347c9572b --- /dev/null +++ b/client/src/components/messages/beeper/BeeperCreatePersonForm.test.jsx @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import BeeperCreatePersonForm from './BeeperCreatePersonForm'; + +/** + * The confirm-and-rename form "Create new…" opens instead of posting + * immediately (fork issue #97 part A). Wiring from `BeeperThread` into this + * component is covered in `BeeperThread.test.jsx`; these pin the form's own + * contract in isolation. + */ + +const PARTICIPANT = { sourceUserId: 'user-1', displayName: 'Sam Example', handle: '+15550100' }; + +const renderForm = (overrides = {}) => { + const props = { + participant: PARTICIPANT, + onCreate: vi.fn(), + onCancel: vi.fn(), + ...overrides, + }; + const utils = render(); + return { ...utils, props }; +}; + +afterEach(cleanup); + +describe('BeeperCreatePersonForm — prefill and fields', () => { + it('prefills the name from the participant\'s display name, editable', () => { + renderForm(); + const nameInput = screen.getByLabelText('Name'); + expect(nameInput).toHaveValue('Sam Example'); + + fireEvent.change(nameInput, { target: { value: 'Corrected Name' } }); + expect(nameInput).toHaveValue('Corrected Name'); + }); + + it('falls back to the handle when there is no display name', () => { + renderForm({ participant: { sourceUserId: 'user-2', displayName: '', handle: '@example_handle' } }); + expect(screen.getByLabelText('Name')).toHaveValue('@example_handle'); + }); + + it('defaults the ring to tribe and offers every RINGS option', () => { + renderForm(); + const ringSelect = screen.getByLabelText('Ring'); + expect(ringSelect).toHaveValue('tribe'); + expect(screen.getByRole('option', { name: 'Support' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Core' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Tribe' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Village' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'External' })).toBeInTheDocument(); + }); + + it('starts with an empty, optional relationship field', () => { + renderForm(); + expect(screen.getByLabelText('Relationship')).toHaveValue(''); + }); +}); + +describe('BeeperCreatePersonForm — Create', () => { + it('is disabled while the name is blank', () => { + renderForm({ participant: { sourceUserId: 'user-3', displayName: '', handle: '' } }); + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); + }); + + it('calls onCreate with the trimmed name, ring and relationship, and only on Create', () => { + const onCreate = vi.fn(); + renderForm({ onCreate }); + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Corrected Name ' } }); + fireEvent.change(screen.getByLabelText('Ring'), { target: { value: 'core' } }); + fireEvent.change(screen.getByLabelText('Relationship'), { target: { value: ' Neighbor ' } }); + expect(onCreate).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + + expect(onCreate).toHaveBeenCalledWith({ name: 'Corrected Name', ring: 'core', relationship: 'Neighbor' }); + }); + + it('is disabled while a link is in flight, and never calls onCreate from a click', () => { + const onCreate = vi.fn(); + renderForm({ onCreate, disabled: true }); + + const createButton = screen.getByRole('button', { name: 'Create' }); + expect(createButton).toBeDisabled(); + fireEvent.click(createButton); + expect(onCreate).not.toHaveBeenCalled(); + }); + + it('submits on Enter from the name field', () => { + const onCreate = vi.fn(); + renderForm({ onCreate }); + + fireEvent.keyDown(screen.getByLabelText('Name'), { key: 'Enter' }); + + expect(onCreate).toHaveBeenCalledWith({ name: 'Sam Example', ring: 'tribe', relationship: '' }); + }); + + it('does not submit on Enter while the name is blank', () => { + const onCreate = vi.fn(); + renderForm({ onCreate, participant: { sourceUserId: 'user-4', displayName: '', handle: '' } }); + + fireEvent.keyDown(screen.getByLabelText('Name'), { key: 'Enter' }); + + expect(onCreate).not.toHaveBeenCalled(); + }); +}); + +describe('BeeperCreatePersonForm — Cancel', () => { + it('calls onCancel on click', () => { + const onCancel = vi.fn(); + renderForm({ onCancel }); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(onCancel).toHaveBeenCalled(); + }); + + it('calls onCancel on Escape from any field', () => { + const onCancel = vi.fn(); + renderForm({ onCancel }); + + fireEvent.keyDown(screen.getByLabelText('Relationship'), { key: 'Escape' }); + + expect(onCancel).toHaveBeenCalled(); + }); +}); diff --git a/client/src/components/messages/beeper/BeeperNetworkLogo.jsx b/client/src/components/messages/beeper/BeeperNetworkLogo.jsx new file mode 100644 index 0000000000..6244407bf9 --- /dev/null +++ b/client/src/components/messages/beeper/BeeperNetworkLogo.jsx @@ -0,0 +1,233 @@ +/** + * Network marks for the Beeper chat surface (#35), ported from the #9 + * prototype's `beeperNetworkLogos.jsx`. + * + * Inline SVG because PortOS pulls no external brand assets, and simplified + * because these are read at 14–26px in the rail and as ~15px badges on a row, + * where silhouette and colour carry recognition rather than detail. + * + * **The map is a rendering hint, never a roster.** The rail and the badges are + * driven by whatever networks the mirror actually holds — #9 records that the + * development machine's nine networks are an outlier (today's free tier caps a + * new account at five, and most installs have one), so a network PortOS has + * never heard of must still render. That is what `Fallback` is for, and it is + * why nothing in this file is ever used to decide WHICH networks exist. + */ + +const Wrap = ({ size, rounded, bg, children, title }) => ( + + {children} + +); + +const svg = (children, extra = {}) => ( + {children} +); + +const MARKS = { + whatsapp: ({ size }) => ( + + {svg( + , + )} + + ), + googlemessages: ({ size }) => ( + + {svg( + , + )} + + ), + discord: ({ size }) => ( + + {svg( + , + )} + + ), + facebook: ({ size }) => ( + + {svg( + , + )} + + ), + // Signal's real mark is a dotted/dashed ring, not a speech bubble — drawn as + // a stroked circle with a dash pattern so it stays unmistakable from the + // solid Google Messages bubble at rail size (#84). + signal: ({ size }) => ( + + {svg( + , + )} + + ), + // Beeper's own mark, for chats Beeper attributes to itself rather than a + // bridged network (#84) — a four-point sparkle keeps the silhouette + // distinct from every rounded/circular network bubble above. + beeper: ({ size }) => ( + + {svg( + , + )} + + ), + instagram: ({ size }) => ( + + {svg( + <> + + + + , + )} + + ), + telegram: ({ size }) => ( + + {svg()} + + ), + slack: ({ size }) => ( + + {svg( + <> + + + + + , + )} + + ), + x: ({ size }) => ( + + {svg()} + + ), +}; + +// Display names for the ids above. A network absent from this map falls back +// to its raw id rather than to a guess. +const LABELS = { + whatsapp: 'WhatsApp', + googlemessages: 'Google Messages', + discord: 'Discord', + facebook: 'Messenger', + signal: 'Signal', + instagram: 'Instagram', + telegram: 'Telegram', + slack: 'Slack', + x: 'X', + beeper: 'Beeper', +}; + +/** Any network PortOS has no mark for still renders: initial on a neutral chip. */ +const Fallback = ({ size, label }) => ( + + {(label || '?')[0].toUpperCase()} + +); + +// Beeper's Facebook/Messenger bridge reports several distinct ids for the +// same one `facebook` mark and label depending on bridge generation and +// login mode (#84): the legacy bridge, its Go rewrite, and the +// Messenger-mode login all land on the same network. Keyed post-normalize +// (lowercase, punctuation stripped), so "Facebook Go" and "facebook-go" both +// match `facebookgo`. +const NETWORK_ALIASES = { + facebookgo: 'facebook', + messenger: 'facebook', + messengergo: 'facebook', +}; + +// The live Facebook-bridge display string has never been observed directly +// (no fixture in this repo, none in any captured log, live mirror off +// limits), so rather than guess one more exact spelling for `NETWORK_ALIASES` +// above, this ordered contains-rule against the normalized id is what makes +// the mapping robust to whichever spelling ("Facebook Messenger", +// "Messenger (Go)", ...) the bridge actually emits — checked only after the +// exact map so a future precise alias still wins. +const NETWORK_ALIAS_RULES = [ + [/facebook|messenger/, 'facebook'], +]; + +// Beeper reports a network as a lowercase id; normalize defensively so a +// bridge that reports "WhatsApp" or "google-messages" still finds its mark +// instead of silently degrading to the initial chip, then fold known aliases +// onto the one id each has a mark/label for. +const normalize = (network) => { + const raw = String(network || '').toLowerCase().replace(/[^a-z0-9]/g, ''); + if (NETWORK_ALIASES[raw]) return NETWORK_ALIASES[raw]; + const rule = NETWORK_ALIAS_RULES.find(([pattern]) => pattern.test(raw)); + return rule ? rule[1] : raw; +}; + +/** A human label for a network id, for the composer, the header and titles. */ +export const networkLabel = (network) => LABELS[normalize(network)] || network || 'Unknown network'; + +// A network id PortOS has no mark for is expected (#9: the roster is +// whatever the mirror holds, never a hardcoded list) but should still be +// visible to a developer diagnosing why a network fell back to a letter +// chip. Log it once per normalized id, dev-only, so a rail full of one +// unrecognized network doesn't spam the console on every render. +const loggedUnknownNetworks = new Set(); + +function logUnknownNetworkOnce(rawNetwork, normalized) { + if (!import.meta.env.DEV) return; + if (!normalized || loggedUnknownNetworks.has(normalized)) return; + loggedUnknownNetworks.add(normalized); + console.warn(`⚠️ Beeper network "${rawNetwork}" (normalized "${normalized}") has no rail mark — falling back to a letter chip`); +} + +export default function NetworkLogo({ network, label, size = 16 }) { + const normalized = normalize(network); + const Mark = MARKS[normalized]; + if (!Mark) { + logUnknownNetworkOnce(network, normalized); + return ; + } + return ; +} diff --git a/client/src/components/messages/beeper/BeeperNetworkLogo.test.jsx b/client/src/components/messages/beeper/BeeperNetworkLogo.test.jsx new file mode 100644 index 0000000000..c1f70c3caa --- /dev/null +++ b/client/src/components/messages/beeper/BeeperNetworkLogo.test.jsx @@ -0,0 +1,149 @@ +import { + afterEach, describe, expect, it, vi, +} from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import NetworkLogo, { networkLabel } from './BeeperNetworkLogo'; + +/** + * #84: the Google Messages and Signal marks were both solid blue speech + * bubbles, indistinguishable at rail size; Facebook and Beeper fell back to + * grey letter chips because the ids Beeper actually emits for those two + * ("facebookgo"/"messenger" for Facebook, and Beeper's own network) never + * normalized onto a key `MARKS` held a mark for. + * + * These tests pin: distinct silhouettes for Google Messages vs. Signal, a + * Beeper mark, every Facebook/Messenger alias resolving to the one + * `facebook` mark and label, the letter-chip fallback for a truly unknown + * id, and the once-per-id dev-only console log that makes that fallback + * visible without spamming. + */ + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('BeeperNetworkLogo — Google Messages vs. Signal', () => { + it('renders Google Messages as a filled speech-bubble path', () => { + render(); + const mark = screen.getByRole('img', { name: 'Google Messages' }); + expect(mark.querySelector('path')).toBeTruthy(); + expect(mark.querySelector('circle')).toBeNull(); + }); + + it('renders Signal as a dashed/dotted ring, not a speech bubble', () => { + render(); + const mark = screen.getByRole('img', { name: 'Signal' }); + const ring = mark.querySelector('circle'); + expect(ring).toBeTruthy(); + // A dotted/dashed circle, not a solid one — the silhouette that actually + // distinguishes it from Google Messages' bubble. + expect(ring).toHaveAttribute('stroke-dasharray'); + expect(ring).toHaveAttribute('fill', 'none'); + expect(mark.querySelector('path')).toBeNull(); + }); +}); + +describe('BeeperNetworkLogo — Beeper mark', () => { + const beeperIds = ['beeper', 'Beeper']; + + it.each(beeperIds)('renders a dedicated Beeper mark instead of falling back for %s', (rawId) => { + render(); + const mark = screen.getByRole('img', { name: 'Beeper' }); + expect(mark.querySelector('path')).toBeTruthy(); + // Not the neutral grey fallback chip's single-letter span. + expect(mark.querySelector('span')).toBeNull(); + }); + + it('exposes "Beeper" through networkLabel', () => { + expect(networkLabel('beeper')).toBe('Beeper'); + }); +}); + +describe('BeeperNetworkLogo — Facebook/Messenger id normalisation', () => { + const aliases = [ + 'facebook', + 'facebookgo', + 'messenger', + 'messengergo', + 'Facebook Go', + 'MESSENGER', + // Beeper reports a display name, not a stable id, and the live + // Facebook-bridge string has never been observed (#84) — these pin the + // contains-rule fallback (`NETWORK_ALIAS_RULES`) that catches whichever + // spelling the bridge actually emits, none of which hit the exact map. + 'Facebook Messenger', + 'facebook-messenger', + 'Facebook (Go)', + 'Messenger (Go)', + ]; + + it.each(aliases)('resolves %s to the Messenger mark', (rawId) => { + render(); + const mark = screen.getByRole('img', { name: 'Messenger' }); + expect(mark.querySelector('path')).toBeTruthy(); + expect(mark.querySelector('span')).toBeNull(); + }); + + it('labels every alias "Messenger" via networkLabel', () => { + for (const rawId of aliases) { + expect(networkLabel(rawId)).toBe('Messenger'); + } + }); +}); + +describe('BeeperNetworkLogo — unknown network fallback', () => { + it('renders the letter-chip fallback for a network with no mark', () => { + render(); + const chip = screen.getByRole('img', { name: 'threema-unmapped-1' }); + expect(chip.textContent).toBe('T'); + }); + + it('prefers an explicit label prop over the raw id on the fallback chip', () => { + render(); + const chip = screen.getByRole('img', { name: 'Threema' }); + expect(chip.textContent).toBe('T'); + }); +}); + +describe('BeeperNetworkLogo — dev-only once-per-id unknown network log', () => { + it('logs an unknown id once, even across repeated renders of the same id', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render(); + render(); + render(); + + const hits = warn.mock.calls.filter(([msg]) => msg.includes('unmapped-dev-log-a')); + expect(hits).toHaveLength(1); + }); + + it('logs a different unknown id independently of one already logged', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render(); + render(); + + const bHits = warn.mock.calls.filter(([msg]) => msg.includes('unmapped-dev-log-b')); + const cHits = warn.mock.calls.filter(([msg]) => msg.includes('unmapped-dev-log-c')); + expect(bHits).toHaveLength(1); + expect(cHits).toHaveLength(1); + }); + + it('does not log for a network that resolves to a known mark', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render(); + render(); + expect(warn).not.toHaveBeenCalled(); + }); + + it('stays silent outside development', () => { + const originalDev = import.meta.env.DEV; + import.meta.env.DEV = false; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + render(); + expect(warn).not.toHaveBeenCalled(); + } finally { + import.meta.env.DEV = originalDev; + } + }); +}); diff --git a/client/src/components/messages/beeper/BeeperOutboxBreakerBanner.jsx b/client/src/components/messages/beeper/BeeperOutboxBreakerBanner.jsx new file mode 100644 index 0000000000..c040f2a796 --- /dev/null +++ b/client/src/components/messages/beeper/BeeperOutboxBreakerBanner.jsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; +import { Loader2, ShieldAlert } from 'lucide-react'; +import Banner from '../../ui/Banner'; +import toast from '../../ui/Toast'; +import { clearOutboxBreaker } from '../../../services/api'; + +/** + * The runaway breaker, on the settings card and nowhere else (#36, decided on + * #8 decision 4 and #12's two-surfaces rule). + * + * The breaker trips when sends arrive faster than a human produces them — a + * software loop, not a busy conversation. It blocks every further send until a + * person clears it here: there is no timed recovery anywhere in the send path, + * because a breaker that resets itself is a delay rather than a breaker. + * + * It renders where actionable Beeper faults already render, never as a global + * banner: a user who is not sending anything has nothing to act on. + * + * @param {object} props + * @param {object|null} props.breaker `status.outbox.breaker` from GET /api/beeper/status. + * @param {() => void} [props.onCleared] refetch hook for the parent's status. + */ +export default function BeeperOutboxBreakerBanner({ breaker, onCleared }) { + const [clearing, setClearing] = useState(false); + if (!breaker?.tripped) return null; + + const handleClear = async () => { + setClearing(true); + const result = await clearOutboxBreaker({ silent: true }).catch((err) => { + toast.error(err?.message || 'Could not clear the send breaker'); + return null; + }); + setClearing(false); + if (!result) return; + toast.success('Beeper sending re-enabled'); + onCleared?.(); + }; + + return ( + + {clearing ? : null} + {clearing ? 'Clearing…' : 'Clear breaker'} + + )} + > + The runaway breaker tripped ({breaker.reason || 'unexpected send rate'}). No message has been sent since, + and nothing is retried automatically. Check what was sending before clearing this. + + ); +} diff --git a/client/src/components/messages/beeper/BeeperPersonPicker.jsx b/client/src/components/messages/beeper/BeeperPersonPicker.jsx new file mode 100644 index 0000000000..09ca3302d2 --- /dev/null +++ b/client/src/components/messages/beeper/BeeperPersonPicker.jsx @@ -0,0 +1,210 @@ +import { useEffect, useId, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Search, UserPlus } from 'lucide-react'; +import usePopoverPosition, { VIEWPORT_PADDING } from '../../../hooks/usePopoverPosition.js'; + +// Matches the list's old `w-48` — kept as a JS constant now that width is set +// via `usePopoverPosition` instead of a Tailwind width utility. +const LIST_WIDTH = 192; + +/** + * Search-first Tribe-person picker for the Beeper participant-linking path + * (#98 part B). It replaces the bare full-roster `` this replaces. "Create new…" is + * always the LAST row, below every match, and calls the exact same + * `onCreateNew` callback the old "New" button called — #97 changes what that + * callback DOES (a confirm-and-rename form instead of an immediate create), + * not this wiring. + * + * `autoFocus` (#97 part B) is only ever passed `true` from "Change" on an + * already-linked participant row, so re-pointing a link opens straight into + * a focused, ready-to-type input rather than requiring an extra click. + * + * The results list is portaled to `document.body` and fixed-positioned via + * `usePopoverPosition` (#105): `BeeperThread.jsx` renders this inside a + * `max-h-40 overflow-y-auto` participants roster, and an absolutely + * positioned child cannot escape an `overflow: auto` ancestor — the list was + * extending that roster's own scroll area instead of floating over it. This + * is a combobox, not a menu: focus never leaves the input while the list is + * open, so closing is still driven by `onBlur` (`closeList`) exactly as + * before, and every pointer target inside the portaled list (not just each + * row) calls `preventDefault` on `mousedown` so clicking anywhere in it — + * padding, the "No matches" row, the scrollbar — counts as inside the picker + * rather than blurring the input out from under a would-be selection. + */ +export default function BeeperPersonPicker({ + id, + label, + people, + onSelectPerson, + onCreateNew, + disabled = false, + placeholder = 'Link to…', + autoFocus = false, +}) { + const [query, setQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const listboxId = useId(); + const optionId = (index) => `${listboxId}-option-${index}`; + const wrapperRef = useRef(null); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedQuery(query), 200); + return () => clearTimeout(timer); + }, [query]); + + const matches = useMemo(() => { + const needle = debouncedQuery.trim().toLowerCase(); + const list = needle + ? (people || []).filter((person) => (person.name || '').toLowerCase().includes(needle)) + : (people || []); + // A ceiling, not a hint that more exist — keeps a very large roster from + // rendering an unbounded results list. + return list.slice(0, 50); + }, [people, debouncedQuery]); + + // "Create new…" is always present and always LAST; the row count for + // keyboard purposes is every match plus that one trailing row. + const createNewIndex = matches.length; + const rowCount = matches.length + 1; + + // Right-aligned below the input (flipping above only when there's no room + // below), re-measured whenever the match count changes the list's height — + // typing can grow or shrink it between "No matches" and a full page of + // rows — so an above/below flip made while the list was short doesn't + // paint stale once it grows. + const { popoverRef: listRef, style: listStyle } = usePopoverPosition({ + open, + width: LIST_WIDTH, + minWidth: LIST_WIDTH, + gap: 4, + position: 'below', + anchorRef: wrapperRef, + contentDeps: [matches.length], + }); + + const closeList = () => { setOpen(false); setActiveIndex(0); }; + + const selectRow = (index) => { + if (index === createNewIndex) onCreateNew(); + else if (matches[index]) onSelectPerson(matches[index].id); + setQuery(''); + closeList(); + }; + + const handleKeyDown = (event) => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + if (!open) { setOpen(true); return; } + setActiveIndex((index) => (index + 1) % rowCount); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + if (!open) { setOpen(true); return; } + setActiveIndex((index) => (index - 1 + rowCount) % rowCount); + } else if (event.key === 'Enter') { + if (!open) return; + event.preventDefault(); + selectRow(activeIndex); + } else if (event.key === 'Escape' && open) { + event.preventDefault(); + closeList(); + } + }; + + return ( +
+ {label && } +
+
+ {open && createPortal( +
    event.preventDefault()} + className="fixed z-[100] max-h-48 overflow-y-auto rounded border border-port-border bg-port-card py-1 shadow-lg" + style={{ + left: listStyle?.left ?? `${VIEWPORT_PADDING}px`, + top: listStyle?.top ?? `${VIEWPORT_PADDING}px`, + width: listStyle?.width ?? `${LIST_WIDTH}px`, + visibility: listStyle ? 'visible' : 'hidden', + }} + > + {matches.length === 0 && ( +
  • No matches
  • + )} + {matches.map((person, index) => ( +
  • { event.preventDefault(); selectRow(index); }} + className={`cursor-pointer truncate px-2 py-1 text-[11px] ${ + index === activeIndex ? 'bg-port-accent/20 text-white' : 'text-gray-200' + }`} + > + {person.name} +
  • + ))} +
  • { event.preventDefault(); selectRow(createNewIndex); }} + className={`flex items-center gap-1 border-t border-port-border/60 px-2 py-1 text-[11px] ${ + createNewIndex === activeIndex ? 'bg-port-accent/20 text-white' : 'text-gray-300' + }`} + > +
  • +
, + document.body, + )} +
+ ); +} diff --git a/client/src/components/messages/beeper/BeeperPersonPicker.test.jsx b/client/src/components/messages/beeper/BeeperPersonPicker.test.jsx new file mode 100644 index 0000000000..24d942ea1f --- /dev/null +++ b/client/src/components/messages/beeper/BeeperPersonPicker.test.jsx @@ -0,0 +1,256 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + cleanup, fireEvent, render, screen, waitFor, +} from '@testing-library/react'; +import BeeperPersonPicker from './BeeperPersonPicker'; + +/** + * The search-first Tribe-person picker (#98 part B) that replaces the bare + * full-roster ` setForm((prev) => ({ ...prev, enabled: e.target.checked }))} + className="w-4 h-4 accent-port-accent" + /> + Enable scheduled Beeper sync + + +
+
+ + setForm((prev) => ({ ...prev, intervalMinutes: e.target.value }))} + className="w-full px-3 py-2 bg-port-bg border border-port-border rounded text-white text-sm" + /> +
+
+ + setForm((prev) => ({ ...prev, attachmentBudgetGb: e.target.value }))} + className="w-full px-3 py-2 bg-port-bg border border-port-border rounded text-white text-sm" + /> +
+
+ + setForm((prev) => ({ ...prev, baseUrl: e.target.value }))} + className="w-full px-3 py-2 bg-port-bg border border-port-border rounded text-white text-sm font-mono" + /> +
+
+ + {/* Loopback-only by default (SEC-2) — a non-loopback base URL would + carry the Beeper access token off this machine on every request. */} + + +
+ +
+
+
+ + {/* The outbound runaway breaker (#36) — an actionable fault, so it renders + on this settings surface with the other actionable faults rather than + on the chat surface or as a global banner. Absent entirely unless it + has actually tripped. `onBreakerCleared` (from `BeeperTab`) refreshes + the page-level snapshot the composer reads, so Send re-enables the + moment this clears rather than waiting on the composer's own next + status fetch. */} + { loadStatus(); onBreakerCleared?.(); }} + /> + + + + {statusLoading ? ( + + ) : ( + + )} +
+ ); +} + +// The two connect paths, side by side rather than one behind the other (#11 +// decision 3): OAuth is the quick path, pasting is the durable one, because +// Beeper's own UI can mint a token that never expires and nothing in the OAuth +// surface accepts a lifetime. The pasted value goes straight to the vaulted +// write path — it is never echoed back, never stored in settings, and never +// read back into this field. +function BeeperConnectPanel({ connect, submitLabel = 'Connect Beeper' }) { + return ( +
+
+ +

+ Opens Beeper's approval screen and asks for read and send access. Tokens issued this way expire. +

+
+ + + +

+ Beeper's own settings can mint a token that never expires — the one credential the approval + flow above cannot produce. PortOS stores it encrypted and never shows it again. +

+
+ connect.onTokenChange(e.target.value)} + placeholder="Access token" + className="flex-1 min-w-0 px-3 py-2 bg-port-bg border border-port-border rounded text-white text-sm font-mono" + /> + +
+ +
+ ); +} + +// Inline two-step confirmation — no window.confirm (client/src/AGENTS.md). +// Disconnecting revokes the credential where the authorization server supports +// it and always deletes the local copy, so it is worth a deliberate second +// click but not a modal. +function DisconnectButton({ connect }) { + if (!connect.confirmingDisconnect) { + return ( + + ); + } + return ( +
+ Forget this Beeper credential? + + +
+ ); +} + +// Shown whenever a stored token is inside the expiry warning window, +// regardless of whether Beeper Desktop is currently reachable — an expired +// token is exactly as actionable on an unreachable install as on a +// connected one, so this renders in both the `reachable === false` and +// `reachable === true` branches below. +function TokenExpiryNotice({ status }) { + if (!status?.tokenExpiringSoon) return null; + const days = status.tokenExpiresInDays; + const label = Number.isFinite(days) && days <= 0 + ? 'Token has expired — reconnect to keep syncing.' + : `Token expires in ${days} day(s) — reconnect soon.`; + return ( +

+ + {label} +

+ ); +} + +// The granted-scopes line (fork issue #78): sits beside `TokenExpiryNotice` +// for the same reason — a read-only grant is exactly the kind of thing that +// should be visible on the card BEFORE a send fails, not discovered from the +// error it causes. `tokenScopes` is always an array (`[]` when unknown), the +// absent-vs-empty rule applied to a source rather than a value: a pasted +// token (#11 decision 3) never carries scopes back from Beeper's own paste +// UI, so `[]` there is honest and gets said out loud; `[]` from anywhere else +// (the legacy plaintext path) has nothing worth claiming, so this renders +// nothing rather than a bogus "no scopes granted" line. +function TokenScopesNotice({ status }) { + const scopes = Array.isArray(status?.tokenScopes) ? status.tokenScopes : []; + if (scopes.length === 0) { + if (status?.tokenSource !== 'pasted') return null; + return ( +

Scopes unknown (pasted token).

+ ); + } + const readOnly = !scopes.includes('write'); + return ( +
+

Scopes: {scopes.join(', ')}

+ {readOnly && ( +

+ + Read-only grant — sending will fail. +

+ )} +
+ ); +} + +// The transport liveness row (#33 decision 4): a Moltworld-shape dot, and — on +// the same card, never in a global banner — the one `app.state` value a human +// has to act on. Rendered in EVERY branch where a token is configured, not only +// the reachable one: the HTTP probe failing is exactly when the socket's own +// liveness and its `needs-login` remedy are worth reading. `initializing` is deliberately absent from the actionable set: +// it was measured lying for 105 continuous seconds on a fully working install, +// so surfacing it would train the user to ignore this line. +const APP_STATE_REMEDY = { + 'needs-login': 'Beeper Desktop needs you to sign in again.', + 'needs-verification': 'Beeper Desktop needs this device verified.', + 'needs-secrets': 'Beeper Desktop is missing its encryption secrets.', + 'needs-cross-signing-setup': 'Beeper Desktop needs cross-signing set up.', +}; + +// The transport stood down because Beeper answered the upgrade with 401/403. +// Reconnecting could only ever produce the same answer, so the fix is a human's. +const TOKEN_REJECTED_REMEDY = 'Beeper Desktop rejected the stored token — reconnect Beeper.'; + +// `showRemedy` exists for the one card that IS the remedy: the expired-token +// branch below already says "reconnect Beeper" in its heading, its body and its +// button, and a token that expired is exactly the token the transport's own +// 401 stand-down reports as `authRejected` — so the dot still belongs there +// (it corroborates that the socket is down for that reason and not looping), +// while a fourth copy of the same instruction does not. +function BeeperRealtimeRow({ realtime, showRemedy = true }) { + // `null` = the transport has not reported yet. Never rendered as offline. + if (!realtime?.state) return null; + const remedy = !showRemedy ? null : (realtime.authRejected + ? TOKEN_REJECTED_REMEDY + : (realtime.appStateActionable ? APP_STATE_REMEDY[realtime.appState] : null)); + return ( +
+ + {remedy && ( +

+ + {remedy} +

+ )} +
+ ); +} + +/** + * The sweep-visibility row (#80), rendered in EVERY reachability state + * alongside `BeeperRealtimeRow` — the drawer card's own copy of the same + * "Syncing… N of M accounts" / "Last synced HH:MM" strip the chat surface's + * list header shows, so a user who opened settings instead of the chat + * surface still sees whether ingestion is doing anything. `null` (no sweep + * has ever run on this install) renders nothing, same absent-vs-never rule + * `BeeperRealtimeRow` follows for a transport that has not reported yet. + */ +function SweepStatusRow({ sweep }) { + if (!sweep) return null; + if (sweep.running) { + const total = Number.isFinite(sweep.accountsTotal) ? sweep.accountsTotal : null; + const done = Number.isFinite(sweep.accountsDone) ? sweep.accountsDone : 0; + return ( +

+ + {total === null ? 'Syncing…' : `Syncing… ${done} of ${total} account${total === 1 ? '' : 's'}`} +

+ ); + } + if (!sweep.finishedAt) return null; + return ( +

+ Last synced {formatClockTime(sweep.finishedAt, { seconds: false })} +

+ ); +} + +/** + * The mirrored account roster (#30), rendered in EVERY reachability state. + * + * These rows come from `beeper_accounts` — PortOS's own mirror — not from a + * live call, which is the whole reason #27 stores them: the card is supposed to + * render with Beeper Desktop closed. Hiding the roster whenever the probe fails + * threw away information the install already had and made an unreachable app + * look like an empty one, so reachability is stated on its own line above and + * the roster stands beside it. + * + * Read-only by design: it never offers to add a network. Joined by `accountId`, + * never by `network`. + * + * `accounts: null` (paired with `error`) is a FAILED mirror read, distinct + * from `accounts: []` (a successful read that legitimately found none yet) — + * the absent-vs-empty sentinel (root AGENTS.md). Collapsing the two would show + * "No accounts mirrored yet" for a DB hiccup, which reads as a healthy, + * disconnected-feeling install rather than the unknown state it actually is. + */ +function AccountRoster({ accounts, error }) { + if (error) { + return ( +
+

Mirrored accounts

+

{error}

+
+ ); + } + const rows = Array.isArray(accounts) ? accounts : []; + return ( +
+

Mirrored accounts

+ {rows.length === 0 ? ( +

No accounts mirrored yet.

+ ) : ( +
    + {rows.map((account) => ( +
  • + {account.displayName || account.accountId} + {account.network || '—'} +
  • + ))} +
+ )} +
+ ); +} + +// Every state the status card can be in, decided at fork issue #11 and +// carried into #30's Acceptance criteria. `reachable` is read with strict +// equality throughout (`=== false` / `=== true` / `=== null`) — never +// truthiness — so the absent-vs-empty sentinel (`null` = not yet probed) +// can never fall through to the "offline" branch. A failed status fetch is +// handled by the `error` branch immediately below, before any of this ever +// runs, so a broken GET can never collapse into "no token configured". +function BeeperStatusCard({ + status, realtime, error, connect, checking, onCheck, checkDisabled, onRetryStatus, retryingStatus, +}) { + if (error) { + return ( +
+
+ +

Could not read Beeper status

+
+

{error}

+ +
+ ); + } + + if (!status?.tokenConfigured) { + return ( +
+
+ +

Connect Beeper

+
+

+ Beeper is a local desktop app that bridges WhatsApp, Discord, Telegram, and other networks into one + API on this machine — PortOS talks to it over loopback, never over the network. +

+ +
+ ); + } + + // An expired credential is its own state, not a generic API failure: there is + // no refresh grant anywhere in Beeper's OAuth metadata, so the only way + // forward is connecting again. Checked BEFORE reachability so a user whose + // token lapsed while Beeper Desktop happens to be closed still gets the + // action that fixes it rather than "unreachable". + if (status.tokenExpired) { + return ( +
+
+ +

Beeper token expired

+
+

+ Beeper issues no refresh grant, so an expired token is reconnected rather than renewed. +

+
+ +
+ +
+
+ ); + } + + if (status.reachable === false) { + return ( +
+
+ +

Beeper Desktop unreachable

+
+

{status.lastProbeError || 'Could not reach Beeper Desktop.'}

+

Checked against {status.baseUrl}.

+ + +
+
+ {/* The mirror still knows which accounts exist even with Beeper closed. */} +
+
+ + +
+
+ ); + } + + if (status.reachable === true) { + // Fork issue #61, decision 7: a probe timeout shortly after Beeper + // Desktop proved itself alive (a real API call, or a live socket ping) + // stays on THIS card — `reachable` never flips to false for it — with an + // inline note naming the latency, rather than jumping to the unreachable/ + // actionable-fault card below. The empty-state ("Connect Beeper") and + // Retry-button branches never see `probeState` at all, so neither can flip + // on a slow probe. + const isSlow = status.probeState === 'slow'; + return ( +
+
+ +

Beeper Desktop connected

+ {status.appVersion && v{status.appVersion}} +
+ {isSlow && ( +

+ + Slow to respond{Number.isFinite(status.probeLatencyMs) ? ` (${status.probeLatencyMs}ms)` : ''} — Beeper Desktop is up but answered the last check slowly. +

+ )} + + + + + +
+ + +
+
+ ); + } + + // reachable === null: a token is configured but the probe never ran (a + // transient gap between saving settings and the status refresh landing). + // Neutral, never rendered as offline. + return ( +
+
+ +

Checking Beeper Desktop…

+
+ + + +
+ ); +} + +/** + * The attachment byte mirror's own card (#37): what is on disk against the + * budget, and the ONE place a bulk backfill can be started. + * + * The backfill is gated behind a consent modal that names the count and the + * byte size first. That is the root AGENTS.md no-unbidden-work policy applied + * to bytes rather than to LLM calls, and it is the same split as + * `meatspacePostDrillCache` / `CacheFillConsentModal`: the incremental + * fetch-on-view needs no prompt because the user opened the thread, while a + * from-zero batch of thousands of files does. + * + * "Mirror all" gates on the SAVED budget, not the form input: the server reads + * `settings.beeper.attachmentBudgetGb` when it decides where to stop, so + * running with an unsaved number would silently use the old one. + */ +function AttachmentMirrorCard({ budgetGb, settingsDirty }) { + const [summary, setSummary] = useState(null); + const [summaryError, setSummaryError] = useState(null); + const [loading, setLoading] = useState(true); + const [consentOpen, setConsentOpen] = useState(false); + const [running, setRunning] = useState(false); + const mountedRef = useMounted(); + + const loadSummary = useCallback(async () => { + const [result, error] = await getBeeperAttachmentSummary({ silent: true }) + .then((value) => [value, null]) + .catch((err) => [null, err]); + if (!mountedRef.current) return; + setSummary(result); + // "The request failed" and "the mirror is empty" are different answers, and + // only one of them means the numbers below are trustworthy. + setSummaryError(error ? (error?.message || 'Could not read the attachment mirror') : null); + setLoading(false); + }, []); + + useEffect(() => { loadSummary(); }, [loadSummary]); + + const handleBackfill = async () => { + setConsentOpen(false); + setRunning(true); + const result = await backfillBeeperAttachments({}, { silent: true }).catch((err) => { + toast.error(err?.message || 'Attachment backfill failed'); + return null; + }); + if (!mountedRef.current) return; + setRunning(false); + if (result) { + toast.success( + `Mirrored ${result.fetched} attachment(s)${result.failed ? `, ${result.failed} unavailable` : ''}` + + `${result.stoppedForBudget ? ' — stopped at the disk budget' : ''}`, + ); + } + loadSummary(); + }; + + if (loading) return ; + + const budgetBytes = summary?.budgetBytes || 0; + const usedBytes = summary?.usedBytes || 0; + const usedPercent = budgetBytes > 0 ? Math.min(100, Math.round((usedBytes / budgetBytes) * 100)) : 0; + const pending = summary?.pendingCount || 0; + + return ( +
+
+ +

Attachment mirror

+
+

+ Attachment bytes are downloaded when you first open the thread that shows them, kept under the + {' '}{budgetGb} GB budget above, and evicted least-recently-viewed first — never a file Beeper can no + longer re-supply, and never one you locked. Photos and files stay on this machine. +

+ + {summaryError ? ( +

{summaryError}

+ ) : ( + <> +
+
+ {formatBytes(usedBytes)} of {formatBytes(budgetBytes)} + {summary?.storedFiles || 0} file(s) mirrored +
+
+
+
+
+ +
+ + + + +
+ +
+ + +
+ + )} + + setConsentOpen(false)} + onConfirm={handleBackfill} + /> +
+ ); +} + +function Stat({ label, value }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +/** + * Names the cost before the transfer starts: how many attachments, how many + * bytes, and — separately — how many the bridge never reported a size for, so + * the total is never quietly presented as complete when it isn't. + */ +function BackfillConsentModal({ open, summary, onCancel, onConfirm }) { + if (!open || !summary) return null; + const unknown = summary.pendingUnknownCount || 0; + return ( + +
+
+ +

Mirror all attachments?

+
+

+ PortOS will download {summary.pendingCount} attachment(s) + {' '}from Beeper Desktop — about {formatBytes(summary.pendingBytes)} + {unknown > 0 && <> plus {unknown} whose size Beeper did not report}. + {' '}It stops when the mirror reaches its {formatBytes(summary.budgetBytes)} budget, skips anything over + {' '}{formatBytes(summary.maxBytes)}, and runs one file at a time so Beeper Desktop stays usable. +

+

+ You do not need this to read attachments: opening a thread mirrors what it shows. This is for having + them all on disk in advance. +

+
+ + +
+
+
+ ); +} diff --git a/client/src/components/messages/beeper/BeeperSettingsPanel.test.jsx b/client/src/components/messages/beeper/BeeperSettingsPanel.test.jsx new file mode 100644 index 0000000000..943981f721 --- /dev/null +++ b/client/src/components/messages/beeper/BeeperSettingsPanel.test.jsx @@ -0,0 +1,632 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +const api = vi.hoisted(() => ({ + getSettings: vi.fn(), + updateSettings: vi.fn(), + getBeeperStatus: vi.fn(), + checkBeeperConnection: vi.fn(), + startBeeperOAuth: vi.fn(), + saveBeeperToken: vi.fn(), + disconnectBeeper: vi.fn(), + getBeeperAttachmentSummary: vi.fn(), + backfillBeeperAttachments: vi.fn(), +})); +const toast = vi.hoisted(() => Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() })); + +vi.mock('../../../services/api', () => api); +vi.mock('../../ui/Toast', () => ({ default: toast })); + +const BeeperSettingsPanel = (await import('./BeeperSettingsPanel')).default; + +const BASE_SETTINGS = { beeper: { enabled: false, intervalMinutes: 5, baseUrl: 'http://127.0.0.1:23373', attachmentBudgetGb: 5 } }; + +// No router: the panel takes `realtime` as a prop and reads nothing off the +// URL — the OAuth outcome flag is the page shell's job, covered by the page's +// own suite. +const renderPanel = (props = {}) => render(); + +const BASE_ATTACHMENT_SUMMARY = { + budgetBytes: 5 * 1024 * 1024 * 1024, + usedBytes: 1024 * 1024, + storedFiles: 2, + pendingCount: 0, + pendingBytes: 0, + pendingUnknownCount: 0, + overCapCount: 0, + unavailableCount: 0, + keptCount: 0, + totalCount: 2, + maxBytes: 32 * 1024 * 1024, +}; + +beforeEach(() => { + vi.clearAllMocks(); + api.getSettings.mockResolvedValue(BASE_SETTINGS); + api.getBeeperAttachmentSummary.mockResolvedValue(BASE_ATTACHMENT_SUMMARY); +}); + +// The three states decided at fork issue #11 and carried into #30's +// Acceptance criteria, plus the defensive fourth (absent-vs-empty sentinel). +describe('BeeperSettingsPanel — status card states', () => { + it('offers both connect paths and nothing else when no token is configured', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: false, reachable: null, lastProbeError: null, accounts: [], + }); + renderPanel(); + + expect(await screen.findByRole('heading', { name: 'Connect Beeper' })).toBeInTheDocument(); + // Both paths are first-class (#11 decision 3), so both are on screen at once. + expect(screen.getByRole('button', { name: 'Connect Beeper' })).not.toBeDisabled(); + expect(screen.getByLabelText('Or paste an access token')).toBeInTheDocument(); + expect(screen.queryByText('Beeper Desktop unreachable')).toBeNull(); + expect(screen.queryByText('Beeper Desktop connected')).toBeNull(); + expect(screen.queryByText('Checking Beeper Desktop…')).toBeNull(); + }); + + it('renders the actionable-fault card with a Retry when a token is present but unreachable', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: false, lastProbeError: 'Beeper request failed: connection refused', baseUrl: 'http://127.0.0.1:23373', accounts: [], + }); + renderPanel(); + + expect(await screen.findByText('Beeper Desktop unreachable')).toBeInTheDocument(); + expect(screen.getByText('Beeper request failed: connection refused')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Retry/ })).toBeInTheDocument(); + }); + + it('renders the connected state with an empty roster when the mirror holds no accounts', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, lastProbeError: null, appVersion: '4.3.73', accounts: [], + }); + renderPanel(); + + expect(await screen.findByText('Beeper Desktop connected')).toBeInTheDocument(); + expect(screen.getByTestId('beeper-roster-empty')).toBeInTheDocument(); + }); + + // Fork issue #78: the granted scopes render beside the expiry line, and a + // grant that includes `write` gets no read-only warning. + it('renders the granted scopes on the connected card', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, lastProbeError: null, accounts: [], + tokenScopes: ['read', 'write'], + }); + renderPanel(); + + expect(await screen.findByText('Beeper Desktop connected')).toBeInTheDocument(); + expect(screen.getByText('Scopes: read, write')).toBeInTheDocument(); + expect(screen.queryByText(/read-only grant/i)).toBeNull(); + }); + + // A grant missing `write` is exactly the read-only-token case #78 exists to + // surface BEFORE a send fails, not after. + it('warns that sending will fail when the grant is missing the write scope', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, lastProbeError: null, accounts: [], + tokenScopes: ['read'], + }); + renderPanel(); + + expect(await screen.findByText('Scopes: read')).toBeInTheDocument(); + expect(screen.getByText('Read-only grant — sending will fail.')).toBeInTheDocument(); + }); + + // A pasted token (#11 decision 3) never carries scopes back from Beeper's + // own paste UI, so an empty array there is said out loud rather than + // rendered as if nothing were known. + it('says scopes are unknown for a pasted token with no scopes', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, tokenSource: 'pasted', reachable: true, lastProbeError: null, accounts: [], + tokenScopes: [], + }); + renderPanel(); + + expect(await screen.findByText('Beeper Desktop connected')).toBeInTheDocument(); + expect(screen.getByText('Scopes unknown (pasted token).')).toBeInTheDocument(); + }); + + // An empty scopes array from anywhere else (the legacy plaintext path) has + // nothing worth claiming — this must not render a bogus "no scopes" line. + it('renders nothing for an empty scopes list that is not a pasted token', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, tokenSource: 'legacy-settings', reachable: true, lastProbeError: null, accounts: [], + tokenScopes: [], + }); + renderPanel(); + + expect(await screen.findByText('Beeper Desktop connected')).toBeInTheDocument(); + expect(screen.queryByText(/Scopes/)).toBeNull(); + }); + + // The drawer card's own copy of the list header's sweep-visibility strip + // (#80) — same status payload, different surface. + it('shows a running sweep on the drawer card', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, lastProbeError: null, accounts: [], + sweep: { + running: true, startedAt: '2026-09-05T10:00:00.000Z', finishedAt: null, reason: 'scheduler', + accountsDone: 3, accountsTotal: 9, chats: 40, messages: 812, + }, + }); + renderPanel(); + + expect(await screen.findByText('Syncing… 3 of 9 accounts')).toBeInTheDocument(); + }); + + it('shows the last-synced time on the drawer card once idle', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, lastProbeError: null, accounts: [], + sweep: { + running: false, startedAt: '2026-09-05T10:00:00.000Z', finishedAt: '2026-09-05T10:04:00.000Z', reason: 'manual', + accountsDone: 9, accountsTotal: 9, chats: 210, messages: 4032, + }, + }); + renderPanel(); + + expect(await screen.findByText(/Last synced \d{1,2}:\d{2}/)).toBeInTheDocument(); + }); + + // The mirrored roster comes from `beeper_accounts`, not from a live call — + // which is why #27 stores it. Hiding it whenever the probe fails threw away + // what the install already knew and made an unreachable app look like an + // empty one, so reachability is stated on its own line and the roster stands + // beside it in every reachability state. + it('keeps the mirrored roster on screen while Beeper Desktop is unreachable', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, + reachable: false, + lastProbeError: 'Beeper request failed: connection refused', + baseUrl: 'http://127.0.0.1:23373', + accounts: [{ accountId: 'acc1', displayName: 'Example WhatsApp', network: 'whatsapp' }], + }); + renderPanel(); + + expect(await screen.findByText('Beeper Desktop unreachable')).toBeInTheDocument(); + expect(screen.getByText('Example WhatsApp')).toBeInTheDocument(); + expect(screen.getByText('whatsapp')).toBeInTheDocument(); + }); + + it('keeps the mirrored roster on screen while the probe has not run yet', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, + reachable: null, + lastProbeError: null, + accounts: [{ accountId: 'acc1', displayName: 'Example WhatsApp', network: 'whatsapp' }], + }); + renderPanel(); + + expect(await screen.findByText('Checking Beeper Desktop…')).toBeInTheDocument(); + expect(screen.getByText('Example WhatsApp')).toBeInTheDocument(); + }); + + it('renders the connected state with the account roster when accounts are mirrored', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, lastProbeError: null, accounts: [ + { accountId: 'acc1', displayName: 'Example WhatsApp', network: 'whatsapp' }, + ], + }); + renderPanel(); + + expect(await screen.findByText('Example WhatsApp')).toBeInTheDocument(); + expect(screen.getByText('whatsapp')).toBeInTheDocument(); + }); + + it('renders the transport liveness dot from the status payload, with its actionable app.state remedy (#33)', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, + reachable: true, + lastProbeError: null, + accounts: [], + realtime: { + state: 'reconnecting', lastEventAt: null, lastPingAt: null, appState: 'needs-login', appStateActionable: true, + }, + }); + renderPanel(); + + await screen.findByText('Beeper Desktop connected'); + expect(screen.getByTestId('connection-status-dot')).toHaveAttribute('data-status', 'reconnecting'); + expect(screen.getByText('Beeper Desktop needs you to sign in again.')).toBeInTheDocument(); + }); + + it('renders the liveness row while Beeper Desktop is unreachable — the probe failing is when it matters', async () => { + // The HTTP probe and the WebSocket are different transports: hiding the dot + // and its remedy inside the reachable branch hid them exactly when a human + // needed them. + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, + reachable: false, + lastProbeError: 'Beeper request failed: connection refused', + baseUrl: 'http://127.0.0.1:23373', + accounts: [], + realtime: { + state: 'connecting', lastEventAt: null, lastPingAt: null, appState: 'needs-login', appStateActionable: true, + }, + }); + renderPanel(); + + await screen.findByText('Beeper Desktop unreachable'); + expect(screen.getByTestId('connection-status-dot')).toHaveAttribute('data-status', 'connecting'); + expect(screen.getByText('Beeper Desktop needs you to sign in again.')).toBeInTheDocument(); + }); + + it('names the remedy when Beeper rejected the stored token (#33)', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, + reachable: null, + lastProbeError: null, + accounts: [], + realtime: { + state: 'down', lastEventAt: null, lastPingAt: null, appState: null, appStateActionable: false, authRejected: true, + }, + }); + renderPanel(); + + await screen.findByText('Checking Beeper Desktop…'); + expect(screen.getByTestId('connection-status-dot')).toHaveAttribute('data-status', 'down'); + expect(screen.getByText('Beeper Desktop rejected the stored token — reconnect Beeper.')).toBeInTheDocument(); + }); + + // #31's expired-token card and #33's 401 stand-down describe the same + // credential from two transports, so they have to read as one story: the dot + // corroborates that the socket is down for that reason (and not looping), + // while the "reconnect Beeper" instruction is said once, by the card. + it('shows the transport down on the expired-token card without repeating its remedy', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, + reachable: null, + lastProbeError: null, + accounts: [], + tokenExpired: true, + tokenExpiresAt: '2020-01-01T00:00:00.000Z', + realtime: { + state: 'down', lastEventAt: null, lastPingAt: null, appState: null, appStateActionable: false, authRejected: true, + }, + }); + renderPanel(); + + await screen.findByText('Beeper token expired'); + expect(screen.getByTestId('connection-status-dot')).toHaveAttribute('data-status', 'down'); + expect(screen.getByRole('button', { name: 'Reconnect Beeper' })).toBeInTheDocument(); + expect(screen.queryByText('Beeper Desktop rejected the stored token — reconnect Beeper.')).toBeNull(); + }); + + it('renders no liveness row at all when the transport has never reported', async () => { + // `realtime` absent is not-yet-known, never "offline" — the same + // absent-vs-empty rule the `reachable` tri-state follows. + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, lastProbeError: null, accounts: [], + }); + renderPanel(); + + await screen.findByText('Beeper Desktop connected'); + expect(screen.queryByTestId('connection-status-dot')).toBeNull(); + }); + + // The absent-vs-empty sentinel (#30 Acceptance): reachable:null must never + // render as offline, even in the (normally unreachable) case where a token + // is configured but the probe never ran. + it('never renders reachable:null as offline', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: null, lastProbeError: null, accounts: [], + }); + renderPanel(); + + expect(await screen.findByText('Checking Beeper Desktop…')).toBeInTheDocument(); + expect(screen.queryByText('Beeper Desktop unreachable')).toBeNull(); + }); + + // The absent-vs-empty rule (root AGENTS.md line 233): a status fetch that + // itself fails must never collapse into "no token configured" — an + // install with a working token whose status request errors would + // otherwise be silently told to connect. + it('never renders "Connect Beeper" when the status fetch itself rejects', async () => { + api.getBeeperStatus.mockRejectedValue(new Error('network down')); + renderPanel(); + + expect(await screen.findByText('Could not read Beeper status')).toBeInTheDocument(); + expect(screen.getByText('network down')).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: 'Connect Beeper' })).toBeNull(); + expect(screen.getByRole('button', { name: /Retry/ })).toBeInTheDocument(); + }); + + // Fork issue #61, decision 7: a probe timeout shortly after a real Beeper + // success stays on the connected card with a latency note, never the + // unreachable/actionable-fault card, and never touches the empty-state + // ("Connect Beeper") or the Retry-button branch. + it('renders the slow probe state on the connected card, with its latency, instead of the unreachable card', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, probeState: 'slow', probeLatencyMs: 3000, lastProbeError: null, accounts: [], + }); + renderPanel(); + + expect(await screen.findByText('Beeper Desktop connected')).toBeInTheDocument(); + expect(screen.getByText(/Slow to respond \(3000ms\)/)).toBeInTheDocument(); + expect(screen.queryByText('Beeper Desktop unreachable')).toBeNull(); + // The connected card's action is "Recheck", never the unreachable card's + // "Retry" — a slow probe must not borrow that copy or its gating. + expect(screen.queryByRole('button', { name: /Retry/ })).toBeNull(); + expect(screen.getByRole('button', { name: /Recheck/ })).toBeInTheDocument(); + }); + + it('renders the ordinary connected card with no slow note for probeState:ok', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, probeState: 'ok', probeLatencyMs: 8, lastProbeError: null, accounts: [], + }); + renderPanel(); + + expect(await screen.findByText('Beeper Desktop connected')).toBeInTheDocument(); + expect(screen.queryByText(/Slow to respond/)).toBeNull(); + }); + + // A failed accounts read is a different, unknown state from a legitimately + // empty roster — collapsing them would show "No accounts mirrored yet" for + // a DB hiccup. + it('shows the account roster as unknown, not empty, when the mirror read failed', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, lastProbeError: null, accounts: null, accountsError: 'Could not read the mirrored account roster', + }); + renderPanel(); + + expect(await screen.findByText('Beeper Desktop connected')).toBeInTheDocument(); + expect(screen.getByTestId('beeper-roster-unknown')).toHaveTextContent('Could not read the mirrored account roster'); + expect(screen.queryByTestId('beeper-roster-empty')).toBeNull(); + }); +}); + +/** + * Audit cluster 08 (A11Y-6): both inline error paragraphs on this panel + * carried no role, and the fetches behind them are silent, so a + * screen-reader user got no signal that the status read or the account + * roster read had failed. + */ +describe('BeeperSettingsPanel — inline errors are announced', () => { + it('exposes the status-fetch failure with role="alert"', async () => { + api.getBeeperStatus.mockRejectedValue(new Error('network down')); + renderPanel(); + + expect(await screen.findByText('network down')).toHaveAttribute('role', 'alert'); + }); + + it('exposes the account-roster failure with role="alert"', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: true, lastProbeError: null, accounts: null, accountsError: 'Could not read the mirrored account roster', + }); + renderPanel(); + + expect(await screen.findByTestId('beeper-roster-unknown')).toHaveAttribute('role', 'alert'); + }); +}); + +describe('BeeperSettingsPanel — settings', () => { + it('saves the complete settings slice and disables Save until dirty', async () => { + api.getBeeperStatus.mockResolvedValue({ tokenConfigured: false, reachable: null, accounts: [] }); + api.updateSettings.mockResolvedValue({ + beeper: { + enabled: true, intervalMinutes: 5, baseUrl: 'http://127.0.0.1:23373', attachmentBudgetGb: 5, allowNonLoopbackBaseUrl: false, + }, + }); + renderPanel(); + + // Exact, not /Save/: the connect card's "Save token" is on screen too. + const saveButton = await screen.findByRole('button', { name: 'Save' }); + expect(saveButton).toBeDisabled(); + + fireEvent.click(screen.getByLabelText('Enable scheduled Beeper sync')); + expect(saveButton).not.toBeDisabled(); + + fireEvent.click(saveButton); + await waitFor(() => expect(api.updateSettings).toHaveBeenCalledWith({ + beeper: { + enabled: true, intervalMinutes: 5, baseUrl: 'http://127.0.0.1:23373', attachmentBudgetGb: 5, allowNonLoopbackBaseUrl: false, + }, + })); + expect(toast.success).toHaveBeenCalled(); + }); + + // SEC-2: the opt-in is off by default and never inferred from the baseUrl + // text — it has to be an explicit, separate checkbox flip. + it('carries the non-loopback opt-in as its own explicit field', async () => { + api.getBeeperStatus.mockResolvedValue({ tokenConfigured: false, reachable: null, accounts: [] }); + api.updateSettings.mockResolvedValue({ + beeper: { + enabled: false, intervalMinutes: 5, baseUrl: 'http://127.0.0.1:23373', attachmentBudgetGb: 5, allowNonLoopbackBaseUrl: true, + }, + }); + renderPanel(); + + const checkbox = await screen.findByLabelText(/Allow a non-loopback base URL/); + expect(checkbox).not.toBeChecked(); + + fireEvent.click(checkbox); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(api.updateSettings).toHaveBeenCalledWith({ + beeper: { + enabled: false, intervalMinutes: 5, baseUrl: 'http://127.0.0.1:23373', attachmentBudgetGb: 5, allowNonLoopbackBaseUrl: true, + }, + })); + }); + + it('disables Retry while the form has unsaved edits, per the save-gating convention', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, reachable: false, lastProbeError: 'refused', baseUrl: 'http://127.0.0.1:23373', accounts: [], + }); + renderPanel(); + + await screen.findByText('Beeper Desktop unreachable'); + const retryButton = screen.getByRole('button', { name: /Retry/ }); + expect(retryButton).not.toBeDisabled(); + + fireEvent.click(screen.getByLabelText('Enable scheduled Beeper sync')); + expect(retryButton).toBeDisabled(); + }); + + // The regression: a failed settings GET used to fall through to DEFAULTS + // silently, so the form rendered as though it had read this install's real + // config — and the next Save PUT those defaults over whatever was actually + // stored. The card must show the failure instead of the (wrong) form. + it('shows the load-failed card instead of the form when settings fail to load, and never offers Save', async () => { + api.getSettings.mockRejectedValue(new Error('network error')); + api.getBeeperStatus.mockResolvedValue({ tokenConfigured: false, reachable: null, accounts: [] }); + renderPanel(); + + expect(await screen.findByText('Could not load Beeper settings')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save' })).toBeNull(); + expect(screen.queryByLabelText('Enable scheduled Beeper sync')).toBeNull(); + }); +}); + +describe('BeeperSettingsPanel — the connect flow (#31)', () => { + beforeEach(() => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: false, reachable: null, lastProbeError: null, accounts: [], + }); + }); + + it('opens the authorization URL the server minted, rather than building one client-side', async () => { + const open = vi.spyOn(window, 'open').mockImplementation(() => null); + api.startBeeperOAuth.mockResolvedValue({ authorizationUrl: 'http://127.0.0.1:23373/oauth/authorize?state=s' }); + renderPanel(); + + fireEvent.click(await screen.findByRole('button', { name: 'Connect Beeper' })); + await waitFor(() => expect(open).toHaveBeenCalledWith('http://127.0.0.1:23373/oauth/authorize?state=s', '_blank', 'noopener')); + expect(api.startBeeperOAuth).toHaveBeenCalledWith({ silent: true }); + open.mockRestore(); + }); + + // Write paths never auto-retry (the connect exchange burns a single-use + // code): one call, one toast, and no second attempt. + it('reports a failed connect once and does not retry', async () => { + api.startBeeperOAuth.mockRejectedValue(new Error('Beeper authorization-server metadata unavailable (404)')); + renderPanel(); + + fireEvent.click(await screen.findByRole('button', { name: 'Connect Beeper' })); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Beeper authorization-server metadata unavailable (404)')); + expect(api.startBeeperOAuth).toHaveBeenCalledTimes(1); + }); + + it('posts a pasted token, clears the field, and refreshes status', async () => { + api.saveBeeperToken.mockResolvedValue({ tokenConfigured: true, tokenExpiresAt: null, tokenSource: 'pasted' }); + renderPanel(); + + const input = await screen.findByLabelText('Or paste an access token'); + expect(input).toHaveAttribute('type', 'password'); + fireEvent.change(input, { target: { value: 'example-beeper-token' } }); + fireEvent.click(screen.getByRole('button', { name: /Save token/ })); + + await waitFor(() => expect(api.saveBeeperToken).toHaveBeenCalledWith('example-beeper-token', { silent: true })); + await waitFor(() => expect(input).toHaveValue('')); + expect(api.getBeeperStatus).toHaveBeenCalledTimes(2); + }); + + it('keeps Save token disabled until something is typed', async () => { + renderPanel(); + const save = await screen.findByRole('button', { name: /Save token/ }); + expect(save).toBeDisabled(); + fireEvent.change(screen.getByLabelText('Or paste an access token'), { target: { value: 'example-beeper-token' } }); + expect(save).not.toBeDisabled(); + }); + +}); + +// An expired credential is its own state: Beeper issues no refresh grant, so +// the only action that helps is connecting again — never a generic error. +describe('BeeperSettingsPanel — expired token', () => { + it('renders the reconnect path rather than the unreachable or connected card', async () => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, tokenSource: 'oauth', tokenExpired: true, tokenExpiringSoon: true, + tokenExpiresAt: '2026-01-01T00:00:00.000Z', tokenExpiresInDays: -3, + reachable: false, lastProbeError: 'connection refused', accounts: [], + }); + renderPanel(); + + expect(await screen.findByText('Beeper token expired')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Reconnect Beeper' })).toBeInTheDocument(); + expect(screen.queryByText('Beeper Desktop unreachable')).toBeNull(); + }); +}); + +describe('BeeperSettingsPanel — disconnect', () => { + beforeEach(() => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, tokenSource: 'pasted', reachable: true, lastProbeError: null, accounts: [], + }); + }); + + // No window.confirm anywhere in the client — the confirmation is inline. + it('confirms inline before disconnecting', async () => { + api.disconnectBeeper.mockResolvedValue({ deleted: true, tokenConfigured: false }); + renderPanel(); + + fireEvent.click(await screen.findByRole('button', { name: /Disconnect/ })); + expect(screen.getByText('Forget this Beeper credential?')).toBeInTheDocument(); + expect(api.disconnectBeeper).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: /Yes, disconnect/ })); + await waitFor(() => expect(api.disconnectBeeper).toHaveBeenCalledWith({ silent: true })); + await waitFor(() => expect(api.getBeeperStatus).toHaveBeenCalledTimes(2)); + }); + + it('cancels without calling the API', async () => { + renderPanel(); + fireEvent.click(await screen.findByRole('button', { name: /Disconnect/ })); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.queryByText('Forget this Beeper credential?')).toBeNull(); + expect(api.disconnectBeeper).not.toHaveBeenCalled(); + }); +}); + +// The attachment mirror card (#37). The bulk backfill is the one path here +// that moves gigabytes, so what is pinned is that it cannot start without a +// consent step that states the cost. +describe('BeeperSettingsPanel — attachment mirror', () => { + beforeEach(() => { + api.getBeeperStatus.mockResolvedValue({ + tokenConfigured: true, tokenSource: 'pasted', reachable: true, lastProbeError: null, accounts: [], + }); + }); + + it('renders the disk picture without starting anything', async () => { + renderPanel(); + expect(await screen.findByText('Attachment mirror')).toBeInTheDocument(); + expect(screen.getByText(/of 5 GB/)).toBeInTheDocument(); + expect(api.backfillBeeperAttachments).not.toHaveBeenCalled(); + }); + + it('names the count and the byte size before the backfill runs, and only then runs it', async () => { + api.getBeeperAttachmentSummary.mockResolvedValue({ + ...BASE_ATTACHMENT_SUMMARY, pendingCount: 12, pendingBytes: 4 * 1024 * 1024, pendingUnknownCount: 3, + }); + api.backfillBeeperAttachments.mockResolvedValue({ fetched: 12, failed: 0, bytes: 4194304, stoppedForBudget: false }); + renderPanel(); + + fireEvent.click(await screen.findByRole('button', { name: /Mirror all attachments/i })); + // The modal states BOTH numbers, and the unknown-size tail separately + // rather than folding it into the total as zero. + expect(await screen.findByText('Mirror all attachments?')).toBeInTheDocument(); + expect(screen.getAllByText(/12 attachment\(s\)/).length).toBeGreaterThan(0); + expect(screen.getByText(/4 MB/)).toBeInTheDocument(); + expect(screen.getByText(/3 whose size Beeper did not report/)).toBeInTheDocument(); + expect(api.backfillBeeperAttachments).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: /Mirror 12 attachment/i })); + await waitFor(() => expect(api.backfillBeeperAttachments).toHaveBeenCalledTimes(1)); + }); + + it('cancels the consent modal without transferring anything', async () => { + api.getBeeperAttachmentSummary.mockResolvedValue({ ...BASE_ATTACHMENT_SUMMARY, pendingCount: 4, pendingBytes: 2048 }); + renderPanel(); + fireEvent.click(await screen.findByRole('button', { name: /Mirror all attachments/i })); + fireEvent.click(await screen.findByRole('button', { name: /^Cancel$/ })); + await waitFor(() => expect(screen.queryByText(/Mirror all attachments\?/)).not.toBeInTheDocument()); + expect(api.backfillBeeperAttachments).not.toHaveBeenCalled(); + }); + + it('reports a failed summary read instead of rendering zeros as the truth', async () => { + api.getBeeperAttachmentSummary.mockRejectedValue(new Error('Database unavailable')); + renderPanel(); + expect(await screen.findByText('Database unavailable')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Mirror all attachments/i })).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/messages/beeper/BeeperThread.jsx b/client/src/components/messages/beeper/BeeperThread.jsx new file mode 100644 index 0000000000..30189f0df6 --- /dev/null +++ b/client/src/components/messages/beeper/BeeperThread.jsx @@ -0,0 +1,955 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + ArrowLeft, Archive, BellOff, ChevronDown, Loader2, Plus, RefreshCw, Send, Trash2, UserPlus, Users, +} from 'lucide-react'; +import NetworkLogo, { networkLabel } from './BeeperNetworkLogo'; +import BeeperAttachment from './BeeperAttachment'; +import BeeperPersonPicker from './BeeperPersonPicker'; +import BeeperCreatePersonForm from './BeeperCreatePersonForm'; +import InlineConfirmRow from '../../ui/InlineConfirmRow'; +import { decodeHtmlEntities, parseMessageBody } from '../../../lib/beeperMessageBody'; +import { formatBytes } from '../../../utils/formatters'; + +/** + * Thread + composer + the inline Tribe-linking action, for the Beeper chat + * surface (#35). Structure ported from the #9 prototype (`BeeperSurface.jsx`): + * date-separator pills, sender avatars, deleted-message placeholders, and a + * composer that names the network it would send on. + * + * Two things this deliberately does NOT do: + * + * - **It does not treat an empty thread as an error or as loading.** History + * depth varies enormously per network (#3), so a mirrored conversation with + * no messages is frequently the true answer. It says that, rather than + * spinning forever or rendering a fault. + * - **It does not name the transport.** #9 wants "… on Google Messages (RCS)"; + * the mirror carries `network` but not `transport` (#27), so the label stops + * at the network rather than inventing one. + * + * Sending itself (#53, wired on the durable outbox from #36) is a thin layer + * over `onSend`/`confirmAndSend`/`cancelConfirmation`/`retryOutboxEntry`/ + * `dismissOutboxEntry`, which the surface supplies from `useBeeperOutbox`. + * This component owns none of the send lifecycle — it only renders what the + * hook reports: the pending/failed/stalled rows in `outboxEntries` (filtered on + * STATE, so a settled `sent` entry shows once as the real mirrored message and + * never as a second bubble — including when its message has aged out of the + * loaded page), and the inline first-contact question when `confirmation` is + * set. Nothing here ever retries a send that touched the + * wire on its own; a `failed` row's "Retry" composes a NEW outbox entry with + * the same text, exactly like typing it again, because Beeper has no + * idempotency key and a client-driven resend of the same row would risk a + * duplicate real message. A stalled `approved` row (PR #60 blocker 1 — a + * send refused before the server could even claim the row, most often + * `OUTBOX_BREAKER_OPEN`) is the opposite case: nothing touched the wire, so + * its "Retry" re-dispatches the SAME row, and it also gets a "Dismiss" to + * give up on it — the original bug was this state having neither. + * + * Direction comes from the mirrored `isSender`, never from comparing + * `senderId` against the local user — `accounts[].user.id` differs from + * `senderID` on every network (#2), so there is nothing to compare against. + * Own messages sit right-aligned with no sender name and no avatar, the + * reference interface's shape. + */ + +/** + * One message body. + * + * Two shapes arrive from Beeper and both are handled here. A PLAIN body is a + * text node with its entities decoded (#59: an ampersand was rendering as the + * five-character `&`, because `normalizeMessageRow` stores what the source + * sent and some bridges send entity-encoded text). An HTML body — 26% of + * messages on a real install, Discord and Matrix — is parsed into an + * allowlisted block/span model by `lib/beeperMessageBody.js` and rendered as + * React elements, since rendering it as a text node showed the tags literally. + * + * Nothing here ever reaches `dangerouslySetInnerHTML`: every branch produces + * elements and text nodes, so a tag outside the allowlist cannot execute, load + * or style anything. + */ +function MessageBody({ body }) { + const blocks = parseMessageBody(body); + if (blocks === null) return

{decodeHtmlEntities(body)}

; + return blocks.map((block, blockIndex) => { + const spans = block.spans.map((span, spanIndex) => { + const key = `${blockIndex}-${spanIndex}`; + let node = span.text; + if (span.bold) node = {node}; + if (span.italic) node = {node}; + if (span.href) { + node = ( + + {node} + + ); + } + return {node}; + }); + return block.type === 'quote' + ? ( +
+ {spans} +
+ ) + :

{spans}

; + }); +} + +const dayLabel = (iso) => { + if (!iso) return 'Unknown date'; + const date = new Date(iso); + const days = Math.floor((Date.now() - date.getTime()) / 86400000); + if (days === 0) return 'Today'; + if (days === 1) return 'Yesterday'; + if (days < 7) return date.toLocaleDateString([], { weekday: 'long' }); + return date.toLocaleDateString([], { day: 'numeric', month: 'short', year: 'numeric' }); +}; + +const clockTime = (iso) => (iso ? new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''); + +const initials = (name) => String(name || '?') + .replace(/^[#+@]/, '') + .split(/\s+/) + .slice(0, 2) + .map((word) => word[0]) + .filter(Boolean) + .join('') + .toUpperCase() || '?'; + +function Avatar({ name, size = 40 }) { + return ( + + ); +} + +/** + * A participant row with the inline "Link to Tribe person" action from #34. + * Three shapes in one control, all local to this row's own `mode` state: + * + * - **`create`** (#97 part A) — the picker's "Create new…" no longer posts + * on click; it opens `BeeperCreatePersonForm` in its place, a + * confirm-and-rename step. Only that form's own Create button calls + * `onCreateAndLink`; Cancel returns to the picker. + * - **picker** — either the row's natural unlinked state, or a linked row + * that clicked "Change" (#97 part B). Choosing a result IS the link + * action (unchanged from #98); on a linked row a "Cancel" link (absent + * when the row was never linked — there is nothing to cancel back to) + * returns to the "Linked · …" display without linking anyone. + * - **linked view** — `tribePersonId` present and `mode !== 'picker'`: + * states who it is (a link straight to the person's Tribe page, #98 part + * C) plus "Change" (reopens the picker, pre-focused) and "Unlink" + * (`onUnlink`, #97 part B). + * + * `mode` resets to the natural view whenever the participant's OWN linked + * state changes out from under it — a link/unlink completing, or any other + * cause of a thread refetch — so a stale open form/picker never survives a + * refresh showing a DIFFERENT link state than the one the row was left open + * on. + */ +function ParticipantRow({ + participant, people, linking, onLink, onCreateAndLink, onUnlink, onOpenPerson, +}) { + const pickerId = `beeper-link-${participant.sourceUserId}`; + const name = participant.displayName || participant.handle || participant.sourceUserId; + const linkedId = participant.tribePersonId || null; + const [mode, setMode] = useState('view'); + + const prevLinkedRef = useRef(linkedId); + useEffect(() => { + if (prevLinkedRef.current !== linkedId) { + prevLinkedRef.current = linkedId; + setMode('view'); + } + }, [linkedId]); + + if (mode === 'create') { + return ( +
  • + onCreateAndLink(participant, fields)} + onCancel={() => setMode('view')} + /> +
  • + ); + } + + const showPicker = !linkedId || mode === 'picker'; + if (showPicker) { + return ( +
  • + + {name} + { onLink(participant, personId); setMode('view'); }} + onCreateNew={() => setMode('create')} + /> + {linkedId && ( + + )} + {linking && } +
  • + ); + } + + return ( +
  • + + {name} + {participant.tribePersonName ? ( + + ) : ( + Linked + )} + + + {linking && } +
  • + ); +} + +/** + * The exact sentence shown for a send the server found stranded in `sending` at + * boot (`SEND_INTERRUPTED`, written by `reconcileOutboxOnBoot` in + * `server/services/beeperOutbox.js`, which owns the identical literal — the two + * bundles cannot share a module, so they share a test instead). + * + * It deliberately does not claim a delivery verdict. The POST was in flight + * when the process died, so whether it landed is unknowable from here; the copy + * points at the chat, because looking is the only thing that actually answers + * it, and Retry composes a new message rather than resending that one. + */ +const SEND_INTERRUPTED_COPY = 'Delivery unconfirmed: PortOS restarted mid-send. Check the chat before retrying.'; + +/** The outbox states that still have something to say above the composer. */ +const RENDERED_OUTBOX_STATES = new Set(['approved', 'sending', 'awaiting-confirmation', 'failed']); + +/** + * The counterpart participant in a 1:1 chat (never called for a group chat — + * see `TitleTribeChip` below, which branches on `conversation.isGroup` first). + * + * There is no per-participant self/is-sender marker anywhere in the mirrored + * schema to filter by: `shapeParticipant` (`server/services/beeperConversations.js`) + * carries `sourceUserId`/`displayName`/`handle`/`tribePersonId`/`tribePersonName` + * only, and `isSender` exists solely on MESSAGES, never on a participant row + * (see the `MessageBody`/`OutboxRow` docs above). No account user id is + * exposed to the client either. What IS true, straight from how the roster is + * built (`normalizeParticipants` in `server/services/beeperSync.js` maps + * `chat.participants.items` verbatim, and Beeper's own `items` for a `single` + * chat never includes the local account's own user — see the 1:1 fixtures in + * `beeperSync.test.js`, always exactly one item): a 1:1 conversation's roster + * IS the counterpart, in full. So the rule here is simply "the first — and by + * construction, only — participant"; if a 1:1 conversation's roster somehow + * carried more than one row (a shape Beeper's own contract for a `single` + * chat does not produce), the first is still used as the best-effort answer + * rather than declining to render a chip at all. + */ +const oneToOneCounterpart = (conversation) => (conversation?.participants || [])[0] || null; + +/** + * The Tribe link beside the thread title (#98 part A). Whether this reads as + * a 1:1 chat or a group chat comes from the mirrored `conversation.isGroup` + * (`beeperSync.js`'s `normalizeChat` sets it straight from Beeper's own + * `chat.type === 'group'`), never from participant count — the participant + * subset is truncated (`hasMoreParticipants`) and can legitimately read back + * as a single row for a group Beeper only handed one member over. + * + * A group chat's affordance is honest about being a subset, not a total: + * Beeper's `Chat` payload carries no total-member count independent of the + * (truncated) roster PortOS stores, so the count shown is `participants.length` + * with a trailing `+` when `hasMoreParticipants` is set — the same qualifier + * the People drawer's own heading already uses just below. + */ +function TitleTribeChip({ conversation, onOpenParticipants, onOpenPerson }) { + if (conversation.isGroup) { + const count = (conversation.participants || []).length; + const label = `${count}${conversation.hasMoreParticipants ? '+' : ''} ${count === 1 ? 'person' : 'people'}`; + return ( + + ); + } + + const counterpart = oneToOneCounterpart(conversation); + if (counterpart?.tribePersonId) { + return ( + + ); + } + + return ( + + ); +} + +/** + * One outbox row — a send that has not yet been confirmed by the mirror, or + * one that failed. Always outbound (right-aligned, no avatar), matching the + * bubble a mirrored `isSender` message renders, so a pending send does not + * visually jump when it swaps for the real thing. + * + * Only ONE of the four outcomes below spins, and the spinner is the exception + * rather than the default. Every state that will not change on its own — a + * failure, an interrupted send, an unconfirmed one, a refused one — resolves to + * a terminal line that says what happened, because a spinner for a state + * nothing can ever advance is a lie the user cannot dismiss, and it survives + * every reload. + * + * `approved` is normally in flight for the moment between the create and + * send requests (`sending` is true). If it is STILL `approved` once nothing + * is actively sending — and it is not the row a first-contact confirmation + * is currently asking about — the send was refused before the server could + * even claim the row, most often `OUTBOX_BREAKER_OPEN` (#36). Left alone + * that renders as a permanent "Sending…" phantom that survives reload (PR + * #60 blocker 1), so it gets the same "stalled" treatment as `failed`: a + * reason, a Retry, and — since nothing here was ever posted to Beeper — a + * Dismiss that gives up on it outright. + * + * The two Retries are not the same action, and the breaker splits them (PR + * #60 blocker 2). A `failed` row's Retry composes a NEW entry through the + * composer's own send path, which the breaker blocks exactly as it blocks + * Send — so with the breaker tripped that button is disabled and carries the + * same reason as the Send button, rather than staying live and doing nothing. + * A `stalled` row's Retry re-dispatches the existing row: the server decides, + * and its 429 toasts, so it stays enabled. + */ +function OutboxRow({ + entry, sending, isConfirming, onRetry, onDismiss, breakerTripped, breakerReason, +}) { + const failed = entry.state === 'failed'; + const interrupted = failed && entry.errorCode === 'SEND_INTERRUPTED'; + // Recorded by the server's 30s fallback when it could not find the message it + // had just sent. The row stays `awaiting-confirmation` on purpose — the send + // may well have been delivered, and marking it failed would invite the one + // mistake that cannot be taken back — so this reads as "sent, unconfirmed", + // carries the reason the server recorded, and offers NO Retry. It never + // changes on its own, so it must never spin. + const unresolved = !failed && entry.errorCode === 'CONFIRMATION_UNRESOLVED'; + const stalled = entry.state === 'approved' && !sending && !isConfirming; + const blocked = failed || stalled; + const confirming = entry.state === 'awaiting-confirmation' || entry.state === 'sent'; + const retryBlocked = breakerTripped && !stalled; + const outcome = blocked ? (failed ? 'failed' : 'stalled') : (unresolved ? 'unconfirmed' : 'pending'); + // An interrupted send gets the copy verbatim and nothing else: prefixing + // "Not delivered" would assert a verdict the crash destroyed the evidence for. + let blockedReason = 'Not sent — the send was refused'; + if (interrupted) blockedReason = SEND_INTERRUPTED_COPY; + else if (failed) blockedReason = `Not delivered${entry.errorMessage ? ` — ${entry.errorMessage}` : ''}`; + return ( +
    +
    +

    {entry.body}

    + + {blocked && ( + <> + {blockedReason} + + {stalled && ( + + )} + + )} + {unresolved && ( + + {`Sent, unconfirmed${entry.errorMessage ? ` — ${entry.errorMessage}` : ''}`} + + )} + {!blocked && !unresolved && ( + + + {confirming ? 'Confirming…' : 'Sending…'} + + )} + +
    +
    + ); +} + +export default function BeeperThread({ + conversation, + messages, + loading, + error, + hasMore, + loadingMore, + onLoadMore, + draft, + onDraftChange, + outboxEntries = [], + sending = false, + confirmation = null, + onSend, + confirmAndSend, + cancelConfirmation, + retryOutboxEntry, + dismissOutboxEntry, + breaker = null, + people, + linkingId, + onLinkParticipant, + onCreateAndLinkParticipant, + onUnlinkParticipant, + onOpenTribePerson, + onBack, + onRetry, + onArchive, + onLowPriority, + onPurge, + purging, + onAttachmentUpdated, + writePending, +}) { + const [peopleOpen, setPeopleOpen] = useState(false); + const [purgeOpen, setPurgeOpen] = useState(false); + const [purgeConfirmation, setPurgeConfirmation] = useState(''); + const bottomRef = useRef(null); + + // Newest-first from the API (the order a chat surface pages in); oldest-first + // for display. Reversing here rather than server-side keeps the cursor + // semantics honest: the API never pretends the oldest message is one call away. + const ordered = useMemo(() => [...messages].reverse(), [messages]); + const senderName = useMemo(() => { + const map = new Map(); + for (const participant of conversation?.participants || []) { + map.set(participant.sourceUserId, participant.tribePersonName || participant.displayName || participant.handle || ''); + } + return map; + }, [conversation?.participants]); + + // Outbox rows still worth showing: anything the mirror has not caught up + // with yet, decided on the entry's own STATE rather than on whether its + // message happens to be in the page currently loaded. `sent` is settled — + // the mirrored message IS the record of it — and `GET /outbox` returns up to + // 50 entries in every state, so a `sent` entry whose message had aged out of + // the newest page used to render forever as a spinning bubble with old text. + // `draft` is excluded for the same reason it has no writer: it is not a send. + // `entries` arrives newest-first (the server's own order, preserved through + // every client-side prepend); reversed to read oldest-first like `ordered`. + const visibleOutbox = useMemo(() => { + const mirroredIds = new Set(messages.map((message) => message.id)); + return [...outboxEntries] + .filter((entry) => RENDERED_OUTBOX_STATES.has(entry.state)) + // The tiebreak, not the rule: an `awaiting-confirmation` row the sweep + // already mirrored would otherwise show twice, once as each. + .filter((entry) => !(entry.messageId && mirroredIds.has(entry.messageId))) + .reverse(); + }, [outboxEntries, messages]); + + const trimmedDraft = draft.trim(); + const breakerTripped = Boolean(breaker?.tripped); + const canSend = trimmedDraft.length > 0 && !sending && !breakerTripped; + const sendDisabledReason = breakerTripped + ? `Beeper sending is blocked by the runaway breaker (${breaker?.reason || 'unexpected send rate'}) — clear it in Beeper settings.` + : (trimmedDraft.length === 0 ? 'Type a message to send' : undefined); + + const handleSendClick = () => { if (canSend) onSend(draft); }; + // Bare Enter stays a newline (the textarea is multi-line); ⌘/Ctrl+Enter is + // the send shortcut, matching the reference interface and #53's spec. + const handleComposerKeyDown = (event) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault(); + handleSendClick(); + } + }; + // A failed row's only recovery: compose the SAME text again as a brand new + // outbox entry. Never a resend of the failed row — see the file docstring. + // A stalled `approved` row (PR #60 blocker 1) is the opposite case: nothing + // ever reached Beeper for it, so retrying re-sends the SAME row instead — + // composing a new one on every click would just manufacture more phantoms + // while the breaker stays tripped. + // + // `clearsDraft: false` on the failed-row path: that send is the OLD row's + // text, not what is in the composer. Clearing on its success would throw + // away a message typed while the failed row sat above it — and drop it from + // storage too, since the surface's `setDraft('')` deletes the persisted + // entry. Only the composer's own Send clears the composer. + const handleRetry = (entry) => { + if (entry.state === 'approved') { retryOutboxEntry?.(entry); return; } + if (!breakerTripped) onSend(entry.body, { clearsDraft: false }); + }; + const handleDismiss = (entry) => { dismissOutboxEntry?.(entry); }; + + // Scrolls to the bottom only when what is actually AT the bottom changed — + // the newest mirrored message, or (once one exists) the newest pending + // send — or the conversation itself changed. Tracked in a ref rather than + // read off `ordered.length`/`visibleOutbox.length` as dependencies (the + // original bug, PERF-8/A11Y-2): "Load earlier messages" only grows `ordered` + // by prepending OLDER messages, which changes `ordered.length` on every + // click without moving `ordered[ordered.length - 1]` — the newest message — + // at all, so keying on the length alone yanked the reader back to the + // bottom on every page-in of history. `ordered` is oldest-first (see + // above), so its newest entry is the LAST one; outbox rows render after it + // and are the true bottom while a send is still pending. + const latestBottomRef = useRef(null); + useEffect(() => { + const newestMessageId = ordered.length ? ordered[ordered.length - 1].id : null; + const newestOutboxId = visibleOutbox.length ? visibleOutbox[visibleOutbox.length - 1].id : null; + const bottomKey = `${conversation?.id ?? ''}:${newestOutboxId ?? ''}:${newestMessageId ?? ''}`; + if (latestBottomRef.current === bottomKey) return; + latestBottomRef.current = bottomKey; + bottomRef.current?.scrollIntoView({ block: 'end' }); + }, [conversation?.id, ordered, visibleOutbox]); + + // A typed confirmation must never survive the conversation it was typed for: + // switching threads with the panel open would otherwise leave a primed Purge + // button pointing at a different chat. + // + // Keyed on a CHANGE of id, not on the id as a dependency: a refetch that + // momentarily resolves `conversation` to null (a reload, a failed poll) would + // otherwise fire this twice and silently close a panel the user is typing + // into — the value is discarded on a real switch, never on a re-render. + const purgeConversationRef = useRef(null); + useEffect(() => { + const id = conversation?.id || null; + if (!id || purgeConversationRef.current === id) return; + purgeConversationRef.current = id; + setPurgeOpen(false); + setPurgeConfirmation(''); + }, [conversation?.id]); + + // Order matters, and this is the whole reason these three are separate + // branches: `loading` and `error` are both reachable with NO conversation — + // a cold deep link whose detail fetch fails (503, 500, offline) leaves + // `conversation` null and `error` set. Answering that with "Pick a + // conversation" renders a named URL as if nothing were selected, with no + // error and no way back: every fetch behind this passes `{ silent: true }`, + // so there is no toast either. "Pick a conversation" is only correct when + // nothing is selected, nothing is in flight, and nothing went wrong. + if (!conversation && error) { + return ( +
    +

    Could not open this conversation

    +

    {error}

    +
    + {onRetry && ( + + )} + +
    +
    + ); + } + + if (!conversation && loading) { + return ( +
    + Loading conversation… +
    + ); + } + + if (!conversation) { + return ( +
    + Pick a conversation +
    + ); + } + + let lastDay = null; + + return ( +
    +
    + + +
    +

    + {conversation.title || 'Untitled conversation'} + {conversation.isMuted && } + setPeopleOpen((open) => !open)} + onOpenPerson={onOpenTribePerson} + /> +

    +

    {networkLabel(conversation.network)}

    +
    +
    + + + + {onPurge && ( + + )} +
    +
    + + {peopleOpen && ( +
    + {/* Beeper truncates a participant list at 20 (list) / 100 (single + GET) with no participants endpoint and no cursor, so this is a + subset by construction — saying so beats implying a roster. */} +

    + Participants{conversation.hasMoreParticipants ? ' (partial — Beeper truncates long rosters)' : ''} +

    + {(conversation.participants || []).length === 0 ? ( +

    No participants mirrored yet.

    + ) : ( +
      + {conversation.participants.map((participant) => ( + + ))} +
    + )} +
    + )} + + {/* The purge confirmation is TYPED, in-drawer, and names both the + conversation and the bytes it is about to free (#13) — not a + `window.confirm`, which the client conventions forbid and which could + not state either fact. It is also explicit that this is a LOCAL + purge: Beeper still has the chat, and the next sweep re-mirrors it. */} + {purgeOpen && onPurge && ( +
    +

    Purge this mirror

    +

    + Deletes PortOS’s copy of {conversation.title || 'this conversation'} + {' '}— its messages, participants and{' '} + {formatBytes(conversation.attachmentBytes || 0)} of mirrored attachment bytes + {conversation.attachmentFiles ? ` across ${conversation.attachmentFiles} file(s)` : ''}. +

    +

    + Beeper itself is untouched: the chat stays on its network, and the next sync will mirror it again. +

    +
    + + setPurgeConfirmation(event.target.value)} + autoComplete="off" + className="w-28 rounded border border-port-border bg-port-bg px-2 py-1 text-xs text-white" + /> + + +
    +
    + )} + +
    + {hasMore && ( +
    + +
    + )} + + {error &&

    {error}

    } + + {loading && ordered.length === 0 && ( +

    Loading messages…

    + )} + + {/* An empty thread is a legitimate steady state, not a spinner and not + an error — history depth varies enormously per network. Gated on + `visibleOutbox` too: the very first message to a brand-new, + genuinely-empty conversation is itself a pending outbox row, and + that is not "no messages mirrored yet" either. */} + {!loading && !error && ordered.length === 0 && visibleOutbox.length === 0 && ( +
    +

    No messages mirrored yet

    +

    + How much history a bridge hands over varies enormously between networks — some backfill years, + some only what has arrived since you connected. An empty thread here is often correct rather than broken. +

    +
    + )} + + {ordered.map((message) => { + const day = dayLabel(message.sentAt); + const showDay = day !== lastDay; + lastDay = day; + const name = senderName.get(message.senderId) || message.senderId || 'Unknown sender'; + const out = message.isSender === true; + return ( +
    + {showDay && ( +

    + {day} +

    + )} +
    + {!out && } +
    + {!out &&

    {name}

    } + {message.unsentAt ? ( +

    + This message was unsent +

    + ) : ( + + )} + {/* Bytes arrive on first view through the mirror route, not + with the message payload — see BeeperAttachment. */} + {(message.attachments || []).map((attachment) => ( + onAttachmentUpdated(message.id, updated) + : undefined} + /> + ))} + + {clockTime(message.sentAt)} + {message.editedAt && · edited} + +
    +
    +
    + ); + })} + + {/* Pending/failed sends. Not yet in `messages` — that arrives only + once the mirror has caught up (#53, on the outbox from #36). */} + {visibleOutbox.map((entry) => ( + + ))} + +
    +
    + + {/* First-contact confirmation (#8 decision 5, wired on #53): PortOS has + never completed a send to this conversation, so the server refused + and asked. Inline, never `window.confirm` — client conventions. */} + {confirmation && ( + + )} + +
    + +
    + + +