Not tested enough but works on my machine
73 KiB
Agent Lessons
Durable rules for AI agents working on this project.
How to use this file
At session start: read agents-docs/LESSONS-INDEX.md only. Open lesson bodies here only for tags that match the task. Do not load this entire file into context by default.
During the session: if the user corrects you, reverts your edit, or re-prompts with the same instruction — record a lesson here and add a one-line entry to LESSONS-INDEX.md before closing the task. See triggers in agents-docs/AGENT_WORKFLOW.md.
Format of a lesson: every entry uses the four-slot template below. Brevity matters — if you can't state the rule in one sentence, the lesson isn't sharp enough yet.
### <short imperative title>
- **Trigger:** what you were about to do that turned out wrong (one line, concrete enough to pattern-match against)
- **Rule:** what to do instead (one sentence, imperative voice)
- **Why:** the consequence of getting it wrong — past incident, hidden constraint, user preference
- **Example:** one concrete instance, ideally a code or command snippet
Keep lessons sharp. Tag each rule with one or two tags in square brackets after the title (e.g. [testing] [migrations]) so future agents can grep for relevance. If a rule no longer applies, delete it — stale rules drown the real ones.
Lessons
app.commandLine.appendSwitch cannot disable the Chromium sandbox [electron] [packaging] [linux]
- Trigger: a packaged Linux build shows only the window background and spams
Unable to access(W_OK|X_OK) /tmp/Creating shared memory in /tmp/... failed, while the same build works when the user types--no-sandbox. - Rule: sandbox and Ozone switches only count when they are on the real command line at process start. Never pair a runtime
appendSwitch('no-sandbox')withappendSwitch('disable-dev-shm-usage')— the first is a no-op because the zygote has already forked, the second takes effect and redirects shared memory into/tmp, which the still-active sandbox denies forever. - Why: electron-builder's AppImage
AppRunis a bash script that execs$APPDIR/<executableName> "$@"and ignores the bundled desktop entry, solinux.executableArgsnever reaches a double-click or terminal launch. Only an installed.desktopfile passes those arguments. - Example:
electron/app/linux-launcher.rules.tsgenerates the launcher thattools/after-pack.jsinstalls in place of the real binary (renamed<name>-bin); it enables--no-sandboxonly where unprivileged user namespaces are denied (Ubuntu 24.04+ AppArmor, hardened kernels), since an AppImage payload is mountednosuidand cannot fall back to the SUID helper.
A hold-on-unknown rule needs every attach site behind it [voice] [webrtc] [realtime]
- Trigger: replacing a strict media gate with "hold an established path when nothing confirms the peer", while some fast path still attaches the track without asking the rule.
- Rule: route every attach site — including the pre-offer shortcut in
createPeerConnectionand every sibling media kind — through the same decision, and refresh all of them on each piece of new evidence. - Why: an ungated attach becomes the guess the rule then protects: the track alone reads as an established path, so
holdkeeps sending indefinitely to a peer that never joined the channel. The strict gate used to erase that mistake on the next pass. - Example:
MediaManager.mayOpenVoicePathToPeer()gates the first offer increate-peer-connection.ts,syncCameraRouting()reusesdecideVoicePathRouting, andnotePeerVoiceReport()callsrefreshVoiceRouting()so a departure report reaches the camera too, not just the mic.
Missing gossip about a peer is not evidence it left voice [voice] [webrtc] [realtime]
- Trigger: gating an outgoing media track on the observer's store copy of the remote user's
voiceState, and detaching whenever that copy is absent. - Rule: close a negotiated media path only on positive evidence — we left voice, the peer itself reported leaving or another channel, or the connection is gone; treat absence as unknown and hold the path. Opening a path still requires confirmation, so a guess never starts sending the microphone.
- Why: the signal server broadcasts
user_leftfor any socket it declares dead, so a suspend or a flaky hop wipes that copy while the peer is still in the channel. The observer then detached its mic permanently — a silent member with no way back through the UI, not even by toggling mute. - Example:
decideVoicePathRouting()intoju-app/src/app/domains/voice-session/domain/logic/voice-path-routing.rules.ts, used byMediaManager.syncVoiceRouting()for the mic andmayHearPeerVoice()for playback gain.
Assert continuity when the state you broke repairs itself [testing] [voice] [verification]
- Trigger: proving a media cut by wiping a peer from the roster and then checking that audio still flows.
- Rule: when the broken state is refreshed by a periodic message, sample the victim second by second across the window instead of asserting an end state.
- Why: peers gossip their voice state every 5s (
VOICE_HEARTBEAT_INTERVAL_MS), so the roster heals moments after the wipe and the mic re-attaches; the end-state check passed against the unfixed code and proved nothing. Only a per-second sample showed the peer losing audio. - Example:
assertUninterruptedInboundAudio(peer, 10)ine2e/tests/voice/roster-loss-preserves-voice.spec.tsfails on the first silent second; the earlierassertTwoWayAudioafter the wipe did not.
Never test suspend/resume against a live-reloading dev server [testing] [dev-shell] [verification]
- Trigger: suspending the machine to check whether a voice call survives sleep/wake, with the windows served by
ng serve. - Rule: disable the dev server's reload before any suspend/resume test (
LIVE_RELOAD=false npm run dev), and treat a renderer reload in the results as an invalid run rather than a product finding. - Why:
ng serve --sslruns Vite over HTTP/2; the suspend destroys that stream, so on resume Vite throwsThe stream has been destroyedfromviteTransformMiddlewareinto the error overlay of every window, and its live-reload client reloads the page. The reload re-bootstraps the app out of the call, so the post-resume readings showed a "connected" peer with zero RTP — which looks exactly like a silently dead call but only meant the reloaded app was no longer in voice. - Example:
dev.shappends--live-reload=falsewhenLIVE_RELOAD=false; the first P7.4 attempt produced 20audio stalledlines that proved nothing.
Keep diagnostic history outside the page you are diagnosing [testing] [verification]
- Trigger: collecting samples into a
window.__probearray in the DevTools console, then reading them back after the disruptive event. - Rule: persist probe samples to
localStorage(or outside the renderer entirely) and stamp each sample with a per-load id, so a reload keeps the history and becomes visible evidence instead of silent data loss. - Why: the event under test is often the very thing that destroys in-heap state; a reload wiped every pre-suspend sample while leaving the old console lines on screen, so the probe looked loaded but
__voiceProbewas undefined and the baseline was gone. - Example:
tools/voice-probe.jsstores samples undermetoyou_voice_probe_v1and reportsRENDERER RELOADEDwhenperformance.timeOriginchanges between samples.
Record whether the user is in a call before calling zero RTP a failure [testing] [voice] [verification]
- Trigger: asserting on inbound/outbound audio packets without also recording voice membership and local mic track state.
- Rule: capture
isVoiceConnected()and the local audio tracks'readyStatein the same sample as the RTP counters, and only call a stall a stall when the client is supposed to be in voice. - Why: peer connections exist for chat data channels regardless of voice, so "connected with zero audio" is the normal reading outside a call; without the voice flag the two cases are indistinguishable and a healthy app looks broken.
- Example:
readLocalMedia()intools/voice-probe.jslogsin-voice mic=live, and the stall check is gated oncurrent.voice === 'in-voice'.
Never answer second-instance by relaunching the app [electron] [dev-shell]
- Trigger: making a second dev launch reuse the open window by restarting the running instance (
app.relaunch(); app.exit(0)). - Rule: handle a second instance in place — focus and
webContents.reloadIgnoringCache()— and never relaunch the process from thesecond-instancehandler. - Why: the relaunched successor inherits the same dev argument and asks for the single-instance lock while the dying parent still holds it, so it is refused as yet another second instance and the pair respawns forever; every generation also exits
0instead of the launcher's handoff code, soconcurrently --kill-otherstears downng serveand the API server, and an in-flightloadURLdies asERR_FAILED (-2)that reads like an unreachable dev server. - Example:
resolveSecondInstanceAction()inelectron/app/second-instance.rules.tsreturns'reload-existing', anddeep-links.tsreloads instead of relaunching.
Never gate a presence indicator on the observer's own participation [ui] [voice] [webrtc]
- Trigger: writing
if (!isUserInCurrentVoiceRoom(...)) return falsebefore reading a remote user's share/camera state. - Rule: decide a remote indicator from the observed user's state alone; keep the observer's own session out of the input entirely.
- Why: a user sharing alone in a voice channel looked idle to everyone outside it, so nobody could tell there was anything to watch — while the peer plane had already delivered the announcement, because
screen-stategoes to every open data channel and not just voice participants. - Example:
shouldShowStreamIndicator()indomains/voice-session/domain/logic/stream-indicator.rules.ts; guarded bye2e/tests/screen-share/outside-voice-live-indicator.spec.ts, where the observer never joins voice.
ERR_FAILED (-2) on a dev loadURL usually means aborted, not unreachable [electron] [dev-shell]
- Trigger: blaming the cert or
ng servewhen Electron logsERR_FAILED (-2) loading 'https://127.0.0.1:4200'. - Rule: read the rejection stack —
stopLoadingListenermeans the navigation was stopped (window destroyed, app exiting), so look for whatever killed the process;SSL=truealready appendsignore-certificate-errors. - Why: the cert and the dev server were fine; the app was exiting underneath the load, and chasing TLS wasted the first pass at the bug.
- Example:
loadDevelopmentClientWithRetry()inelectron/window/dev-client-load.rules.tsretries and never throws, so the window still gets its listeners and shows a readable failure page.
Pin a chosen media device with deviceId: { exact }, never a bare string [webrtc] [media] [electron]
- Trigger: the user picks a different microphone or camera and nothing changes — not mid-call, not after leaving and rejoining voice.
- Rule: build
getUserMediaconstraints asdeviceId: { exact: id }, and handleOverconstrainedError/NotFoundErrorby retrying once with the system default. - Why: a bare
deviceId: idis anidealconstraint, so Chromium may satisfy it with the device it already had; the feature then looks broken while every unit test passes.exactmakes the request fail loudly instead, which is why it needs the explicit fallback so an unplugged device degrades rather than killing the call. - Example:
buildMicrophoneConstraintsinaudio-device-selection.rules.tsplus the single retry withSYSTEM_DEFAULT_AUDIO_DEVICE_IDinmedia.manager.tscaptureMicrophoneanddirect-call.service.tscaptureCallMicrophone.
A second dev Electron window needs its own --user-data-dir [electron] [dev-shell]
- Trigger: launching a second desktop instance for a two-user test; the existing window blinks and reloads and no second window appears.
- Rule: launch the peer with its own
--user-data-dir(npm run dev:peer), and never launch the desktop shell from an agent shell. - Why: Electron's single-instance lock is scoped to the
userDatadirectory, so a default-directory launch hands its argv to the running instance instead;tools/launch-electron.jsalways appends--metoyou-dev-reload-existing, and thesecond-instancehandler inelectron/app/deep-links.tsanswers that withapp.relaunch(); app.exit(0). Separate data dirs are also what give the two windows separate identities. - Example:
dev-peer.sh—--user-data-dir="$DIR/.dev-userdata/$PEER_NAME".
An outage test that only re-checks the end state is not a guard [testing] [verification] [webrtc]
- Trigger: writing or trusting a test that breaks something (kills a server, closes a channel), then asserts the feature works again afterwards.
- Rule: also assert what must not have happened in between — for a call, that the
RTCPeerConnectionwas never rebuilt (countCreatedPeerConnectionsunchanged) — and prove the assertion by temporarily injecting the regression. - Why: re-checking only the end state passes for a client that tore the call down and rebuilt it, which the user hears as a dropped call. Injecting
peerManager.closeAllPeers()on signaling reconnect kept every audio and peer-count assertion green; only the connection-count assertion failed. - Example:
e2e/tests/voice/recovery-preserves-media.spec.ts— "The call was never rebuilt behind the user back" compares counts captured beforetestServer.kill().
coturn hands out a relay candidate but refuses loopback peers by default [testing] [webrtc] [turn]
- Trigger: a relay-only test (
iceTransportPolicy: 'relay') where candidates gather fine but every peer connection ends upclosed. - Rule: run a local coturn with
--allow-loopback-peers(plus--log-file=stdout --verbose, ordocker logsstays empty and readiness cannot be observed). - Why: without it coturn still allocates and Chrome still reports a
typ relaycandidate, so the failure looks like broken app code rather than a blocked relay; connectivity checks to the other 127.x browser are simply dropped. - Example:
e2e/helpers/turn-server.ts—--allow-loopback-peersnext to--relay-ip=127.0.0.1.
Swap a live device with replaceTrack; an empty device list is missing evidence [voice] [webrtc] [devices]
- Trigger: a settings picker changes a capture device (mic, camera) while a session is live, or code reacts to
devicechangeby re-readingenumerateDevices(). - Rule: re-capture, then
replaceTrackon the existing senders and stop the old track — never tear the session down and rejoin. Treat an empty (or id-less) device list as no information: only fall back to the system default when a populated list proves the saved id is gone. Ask fordeviceIdas a preference, notexact. - Why:
voice-controls.component.tscalleddisconnect()thenconnect()for a mic change, so every peer saw a leave/rejoin and the user lost the channel; the settings pickers wrotelocalStorageand applied nothing.enumerateDevices()returns[]before microphone permission is granted and Firefox never lists audio outputs, so "not in the list" would silently reset a valid choice on startup. A plain track swap on an already negotiated sender needs no SDP exchange, so the swap is invisible to peers. - Example:
MediaManager.switchInputDevice()+resolveAudioDeviceSelection()/buildMicrophoneConstraints()indomains/voice-session/domain/logic/audio-device-selection.rules.ts, owned byVoiceAudioDeviceService; proven bye2e/tests/voice/live-input-device-change.spec.ts(outbound audio keeps flowing, no rejoin broadcast).
One owner for a toggle the UI mirrors [voice] [state] [ui]
- Trigger: two surfaces (in-channel controls and a settings modal, a tray and a window) each keep a local
signalfor the same boolean — mute, deafen, camera on. - Rule: keep the state where the effect happens and let every surface read it back through a
computed; never reset a mirror to a hardcoded value on teardown. - Why:
MediaManagerownedisMicMuted/isSelfDeafened, butvoice-controls.component.tskept its own copies and reset them tofalseindisconnect(), so after leaving voice the button said unmuted while the track was still disabled — and playback was un-deafened behind the user's back. - Example:
isMuted = computed(() => this.webrtcService.isMuted())invoice-controls.component.ts;disconnect()passes the real state intovoicePlayback.updateDeafened().
A timed-out sync round is not a clean one [messages] [realtime] [verification]
- Trigger: deciding a poll/backoff cadence (sync, presence, reconciliation) from a timeout firing with nothing received, or from a fire-and-forget send that "asked" every peer.
- Rule: model the round — who was actually reached, who replied, what they reported — and let only a fully answered round with nothing outstanding buy the slow cadence; re-arm the timer from the verdict of the round that just closed, never from the previous one.
- Why:
messages-sync.effects.tssetlastSyncClean = trueinsidesyncTimeout$, so a round nobody answered dropped the poll from 10s to 15min;sendToPeeralso returnedvoidand only logged when the channel was closed, so peers listed ingetConnectedPeers()(filled atconnectionState === 'connected', before the data channel opens) counted as asked. On top of that,repeat({ delay })read the flag at emission time, so a round that discovered missing ids was already committed to a 15-minute wait. - Example:
message-sync-round.rules.ts(createInventoryRound/recordInventoryReply/isInventoryRoundClean) plusmessages-sync.effects.spec.ts, which advances fake timers and asserts the fast cadence survives silence, a partial answer, an undelivered request, and a late reply reporting missing ids.
Derive a conversation id from canonical humans, never from the ids on the wire [direct-message] [identity]
- Trigger: building or trusting a composite id (DM thread, call id, dedupe key) made of participant ids that arrived in a payload or came from a roster entry.
- Rule: resolve every id through an alias index first (
buildDirectParticipantAliasIndex→getCanonicalDirectConversationId/canonicalizeDirectConversationId), and collapse already-stored alias copies on first touch instead of only fixing new ones. - Why:
getDirectConversationIdsorted the raw pair, so a peer who addressed the local user by a provisioned foreign actor id produced a second thread; the recipient saw two conversations for one human and clicking the peer opened the empty one. Matching aliases for admission was already in place, which made the fork look like a delivery bug instead of an id bug. - Example:
e2e/tests/chat/cross-signal-dm-identity.spec.tsfails withelement(s) not foundfor the peer's message the moment the self-alias group is dropped fromDirectMessageService.participantAliasIndex().
Report whether a call event was delivered before showing a live call [direct-call] [verification]
- Trigger: calling a fire-and-forget send (
sendCallEvent, broadcast, notify) and then moving the UI into the success state. - Rule: return the transport result, ring before joining local media, and surface "reached nobody" through the same error signal the view already renders.
- Why:
startCalljoined voice first and dropped the boolean fromPeerDeliveryService.sendCallEvent, so a call to an unreachable peer showed the caller in a live-looking session that would never connect. - Example:
DirectCallService.ringParticipantssetsdeliveryError(call.errors.ringUndelivered) andprivate-call.component.tsfolds it intocallErrorMessage; the e2e drives it withwindow.simulateOffline()on the caller.
Never spend a retry budget on attempts the transport cannot deliver [realtime] [recovery]
- Trigger: writing or reviewing a bounded retry loop (peer reconnect, resync, delivery) that counts attempts before checking whether the channel it needs is even available.
- Rule: check the dependency first and defer without counting; spend an attempt only when it can actually reach the far side, and when the budget really does run out publish a state the UI can show and re-arm the loop when the dependency returns.
- Why:
peer-recovery.tsincrementedreconnectAttemptsbeforeisSignalingConnected(), so a ~60s signal outage burned all 12 attempts doing nothing, then cleared the timer and deleted the tracker entry with no user-visible state and no re-arm — the peer stayed dead until an unrelated roster event happened to heal it. - Example:
schedulePeerReconnectnow defers while signaling is down, emitspeerRecoveryStatus${ status: 'failed' }at exhaustion, andresumeStalledPeerRecovery()re-arms fromhandleSignalingConnectionStatus.
Repair a dead data channel on the live connection before rebuilding the peer [realtime] [webrtc] [recovery]
- Trigger: handling a closed/failed
RTCDataChannelby tracking the peer as disconnected and rebuilding the wholeRTCPeerConnection. - Rule: while the connection is still
connected, have the deterministically elected initiator create a replacement channel on that same connection (no renegotiation needed — the SCTP transport is already up) and let the other side adopt the incoming channel; rebuild only as the fallback when the replacement never opens. - Why: the control channel dying took voice, camera, and screen share down with it, and
replaceDataChannelwas already implemented and wired but never called — the spec assertednot.toHaveBeenCalled()and the README described the soft replacement as if it shipped. - Example:
e2e/tests/voice/recovery-preserves-media.spec.tsasserts the created-RTCPeerConnectioncount stays at 1 per peer aftercloseOpenDataChannels; forcing the rebuild path makes it fail.
Compare peer ids only within one signal server's identity space [realtime] [identity] [webrtc]
- Trigger: about to compare a remote
peerId/ rosteroderIdagainst a local id — deterministic initiator election, offer-collision politeness, reconnect election, self-filtering, or theoderIdstamped into a voice/camera/screen payload. - Rule: resolve the local id for that peer's signal server (
getLocalOderIdForSignalUrlwhere thesignalUrlis in hand,getIdentifyCredentialsForPeerinside the peer manager) and elect roles only throughpeer-role.rules.ts; never reach for the home credential. - Why: one human has a different actor id per signal server, so a home-vs-foreign comparison is not antisymmetric — both peers offer (glare) or neither does until the 5s takeover, which is the "some users can't hear each other" report. It also makes your own foreign roster entry fail the self-check, so the client tries to peer with itself.
- Example:
realtime-session.service.tswiredgetLocalOderIdtogetIdentifyCredentials()(always home) whileshouldInitiatePeercompared it against foreign roster ids.
Reproduce initiator/glare bugs with a simultaneous reconnect, not staggered joins [testing] [realtime] [webrtc]
- Trigger: writing an e2e for peer election, glare, or "cannot hear each other" and joining clients one after another.
- Rule: get every client onto the roster, then reload/reconnect them with
Promise.allso all pairs elect from the same snapshot, and assert real audio flow plus exactly one initiator per pair. - Why: staggered joins let one side's 1s fallback-offer timer serialize negotiation, so a wrong comparison still converges and the test passes on broken code — three sequential-join runs passed against the known-bad wiring before the simultaneous reconnect made it fail on audio.
- Example:
e2e/tests/voice/cross-signal-initiator-election.spec.ts— 4 users, 2 home signal servers, one shared voice channel,Promise.all(reload).
Interview before coding; don’t guess the fix [workflow] [bugs] [tokens]
- Trigger: about to edit product code for a bug/feature after reading the ask or Obsidian note, while acceptance, approach, or scope is still ambiguous or has real alternatives.
- Rule: send a short interview (understanding, gaps, A/B/C + recommended default, proposed scope, proof of done), wait for the user’s choices, then implement only that — skip only if they said “just fix it” / “no interview.”
- Why: unprompted guesses cause wrong fixes and expensive back-and-forth; one clarifying turn costs less than a wrong implementation thread.
- Example:
fix bug "Images and files in chat doesn't load"→ read the note → ask whether the failure is channel-switch blank vs cold reload vs both before touching attachment services.
Default to toju-app/ + targeted electron/ + CI; do not crawl the monorepo [workflow] [tokens] [scope]
- Trigger: about to browse all of
electron/, or togrep/Readunderserver/,e2e/,website/, ordocs-site/on a normal product bug without the user naming those packages. - Rule: stay in
toju-app/,.gitea/workflows/, and only the Electron files on the renderer→preload→handler path; if the fix looks likeserver//e2e, ask once instead of exploring those trees. - Why: monorepo-wide (and whole-
electron/) exploration multiplies context on expensive problem-solving models without fixing the asked client bug. - Example: attachment disk restore →
toju-apppersistence service +electron/preload.ts+ the one IPC/file helper involved — not every file underelectron/migrations/orelectron/api/.
Write HANDOFF.md and ask the user for a new chat — agents cannot open chats [workflow] [tokens] [handoff]
- Trigger: the thread is long, the user says "handoff"/"new chat", or a new major objective starts while more work remains.
- Rule: overwrite (never append)
agents-docs/HANDOFF.mdwithStatus: activeand short sections, ask the user to start a new chat with that file; when the handoff task is finished, clear the file toStatus: nonewith empty sections. - Why: fat chat history dominates token burn; an appending handoff file becomes a second fat archive that every new chat reloads.
- Example: user: "handoff" → replace HANDOFF → reply: "Start a new chat and attach
@agents-docs/HANDOFF.md." Later when done → reset HANDOFF to emptyStatus: none.
Prove the asked behavior; unit-green is not done [verification] [testing] [workflow]
- Trigger: about to report a task finished because colocated Vitest specs (or a narrow mocked unit) are green, while the user’s ask was a product behavior, UI flow, or bug they can still reproduce.
- Rule: treat acceptance as “the asked functionality works” — prove it with a user-visible path, focused e2e, or an explicit manual check; keep unit tests as support, never as the sole done signal.
- Why: agents optimized for TDD often stop at implementation-shaped tests that pass while the real feature/bug remains broken, which wastes follow-up turns and burns tokens on false completion.
- Example: for “DM reply doesn’t show for the caller,” a passing
DirectMessageServicemock test is insufficient until the cross-signal conversation identity path is exercised (e2e or a behavior-level regression that fails on the old fork-thread bug).
Keep NgOptimizedImage off runtime blob and data URLs [angular] [images]
- Trigger: Angular template lint suggests replacing
[src]withngSrcfor a user-uploaded image rendered fromblob:ordata:. - Rule: Keep a plain
srcbinding, document/disableprefer-ngsrc, and use native loading/decoding plus the app's own lifecycle controls; Angular throwsNG02952for blob/datangSrc. - Why:
NgOptimizedImagetargets network/CDN images and cannot resize, preload, or safely manage renderer-created attachment blobs. - Example: chat attachment thumbnails use
[src]="attachment.objectUrl" loading="lazy" decoding="async", never[ngSrc].
Read the exact Obsidian bug note before diagnosing a named ticket [workflow] [bugs]
- Trigger: The user says
fix bug "…", names aBug - …ticket, or the worktree already contains plausible changes / a similarly named resolved ticket. - Rule: Resolve and read only that note under
Log/Bugs/(and its attachment folder if needed); use Expected Result as acceptance; fix in default scope; do not list the whole inbox or treatBUG_TRACKER.md's snapshot table as live. - Why: attachment reload-host changes looked related to “Images and files in chat doesn't load” but came from a separate resolved ticket and did not cover the reported channel-switch state regression; inbox-wide reads also burn tokens for no gain.
- Example:
fix bug "Images and files in chat doesn't load"→ read/home/ludde/Nextcloud/Obsidian Vault/Log/Bugs/Bug - Images and files in chat doesn't load.mdonly, then implement against its Steps/Expected.
Run npm run i18n:sync after editing any public/i18n/catalog/*.json file [i18n] [testing]
- Trigger: Added new
call.errors.*keys totoju-app/public/i18n/catalog/call.jsonand used them in code; the full test run failed inapp-i18n-catalog.rules.spec.tswith "Missing i18n keys" even though the keys existed in the catalog file. - Rule: The runtime and the catalog spec read the merged
toju-app/public/i18n/en.json, not the per-areacatalog/*.jsonfiles — after any catalog edit, runnpm run i18n:sync(root script,tools/sync-app-i18n-catalog.mjs) and commit the regenerateden.jsonalongside the catalog change. - Why: without the sync the new strings silently fall back to raw keys at runtime and the catalog spec fails, but only in the full suite — targeted spec runs of the feature under change pass, so the failure surfaces late.
- Example:
npm run i18n:sync && npm run testafter addingcall.errors.microphonePermissionDeniedtocatalog/call.json.
Match direct-call recipients against every local identity alias, exactly like DMs already do [direct-call] [identity]
- Trigger: "User receiving direct call doesn't get notified" — a caller who met the callee through a room on the caller's signal server addressed the ring by the callee's provisioned actor id;
handleIncomingCallEventadmitted onlypayload.participantIds.includes(oderId || id), so the ring was silently dropped, the caller sat "In Voice", and the callee saw nothing. DMs had the identical bug fixed earlier (baa350e), but the fix stopped atDirectMessageServiceand never reachedDirectCallService. - Rule: every self check on a cross-user event (admission, sender-echo filter, remote-participant filtering, DM-header peer lookup) must span all local aliases — home id, entity id, peer id, plus each
SignalServerCredentialStoreService.listValidCredentials()actor id — and incoming aliases must be normalized onto the canonical local id before session state is keyed (normalizeDirectCallPayloadSelfAliases). - Why: the failure only reproduces when caller and callee have different home signal servers, which no same-server e2e covers; and when one identity-alias bug is fixed in a domain, grep for the same
=== currentUserIdpattern in sibling domains that share the transport — the direct-call domain reusedPeerDeliveryServicebut kept the naive check for another month. - Example:
direct-call-participant-identity.rules.ts#directCallPayloadIncludesAnyId/normalizeDirectCallPayloadSelfAliases; regression e2ee2e/tests/voice/dm-header-call-ring.spec.tsregisters Bob on a secondary signal server, meets in a primary-signal room, and asserts the DM-header call rings Bob's incoming-call modal (fails on old code, passes after).
Resolve outbound direct-call recipient ids to the peer's connected signal identity [direct-call] [identity] [signaling]
- Trigger: cross-signal direct calls still failed after the inbound alias fix — the caller joined voice and showed "In voice" while the callee never rang.
PeerDeliveryService.resolveSignalingPeerIdreturned null when the stored peer id was a home id but presence/route was registered under the provisioned actor id, sosendRawMessagewas never called; even when attempted, the server relays only whentargetUserIdexactly matches the callee's connectedoderId. - Rule: outbound DM/call delivery must collect every recipient alias (
peer-delivery-identity.rules.ts#collectRecipientDeliveryCandidateIds), pick the routable id withpickRoutableRecipientId, always attempt signaling send (broadcast fallback when no single route works), and surfacecall.errors.recipientUnreachableto the caller when delivery cannot succeed — never leave the caller in a silent "In voice" state. - Why: inbound and outbound identity bugs are independent; fixing admission on the callee does not help if the ring never leaves the caller or hits the wrong
targetUserIdon the wire. - Example:
PeerDeliveryService.sendViaSignaling+DirectCallService.resolveRoutableRecipientId; e2ee2e/tests/voice/dm-header-call-ring.spec.ts(callee-home room, people-search call).
Decide attachment receive admission once at request time; never re-gate size in the chunk handler [attachments]
- Trigger: "Sending files between users doesn't really work" — a browser user clicked Request on a 10–50 MB generic file, the request gate (
canReceiveAttachment) admitted it for in-memory receive, the sender streamed chunks, buthandleFileChunkstill had a leftover hardsize > MAX_AUTO_SAVE_SIZE_BYTESrejection on the in-memory path, so every chunk was dropped, no ack was ever sent, the sender'swaitForAcktimed out, and the GUI never changed. - Rule:
canReceiveAttachment(request time) is the single admission decision; the chunk handler may only route between disk-streaming and in-memory assembly — any stricter size check there silently drops chunks the request gate already admitted. - Why: the failure is invisible in logs-from-the-outside: the sender's per-chunk sends look like a working transfer ("packages with size 32kb") until the ack timeout, and the receiver sets
requestErroronly into memory that a re-request immediately clears — the user just sees a dead Request button. - Example: removed the
MAX_AUTO_SAVE_SIZE_BYTESguard inattachment-transfer.service.ts#handleFileChunk; regression e2ee2e/tests/chat/large-generic-file-transfer.spec.tssends an 11 MB.binbetween two browser clients and asserts Request → progress → Download (fails on the old code, passes after).
Re-queue attachment auto-downloads on every message/room binding event; never trust one transport's ordering [attachments] [realtime]
- Trigger: cross-user attachment sync e2e (
chat-message-features.spec.ts) flaked ~50%:file-announce(WebRTC data channel) beatchat-message(signaling websocket) to the receiver, so the announce-time auto-download resolvedroomId=null, silently gave up, and nothing ever retried — the receiver showed "Waiting for image source..." forever. A related bug: the stalled-download reset keyed only on "receivedBytes>0 && no pending request", but the pending-request marker is deleted on the first chunk, so any auto-download pass during an active transfer cancelled it mid-stream and the retry deadlocked against the sender's active-transfer dedupe. - Rule: events that complete the
messageId -> roomIdbinding (chat-messageinmessages-incoming.handlers.ts) must callqueueAutoDownloadsForMessageagain — never assumefile-announcearrives after the message, they ride different transports; and stall detection must gate on chunk-progress staleness (lastUpdateMsolder thanATTACHMENT_STALLED_DOWNLOAD_THRESHOLD_MS), never on the absence of a pending-request marker alone. - Why: both halves fail silently (no error, no requestError set), so the UI just sits at 0 bytes; the flake is timing-dependent and invisible in single-client tests — only the two-client e2e with
--repeat-eachexposed it deterministically enough to fix. - Example:
handleChatMessagenow callsattachments.queueAutoDownloadsForMessage(message.id)afterrememberMessageRoom;shouldResetStalledAttachmentDownload(attachment, hasPendingRequest, nowMs)inattachment-autodownload.rules.ts. Verified withnpx playwright test -g "syncs image and file attachments|syncs multi-chunk" --repeat-each=4(8/8 after, ~50% before).
Scope per-user UI state by user id, not by the client database [persistence] [multi-user] [custom-emoji]
- Trigger: custom emoji "saved library" membership was a single
savedByUserflag on the shared emoji row plus a long-lived singleton (CustomEmojiService) that merged state across logins — so a second account on the same client (and the Electron shared SQLite DB) inherited the first user's picker. - Rule: when state is "per signed-in user" but the asset/row store is shared (Electron
custom_emojis, or a renderer singleton that survives logout), key the membership by user id in its own store (localStoragemetoyou_custom_emoji_saved:<userId>, mirroring the existing per-user usage ranking) and rebuild it inloadForUser; never rely on a global row flag or assume the singleton was reset on logout. - Why: the browser already isolates rows per-user database, so the leak only reproduces in-session (no reload) and on Electron's shared DB — both invisible if you only test reloads; a row-level flag also can't represent two local users saving the same asset.
- Example:
CustomEmojiService.resolveSavedIds(userId, emojis)reads/seeds a per-user id set; e2ee2e/tests/chat/custom-emoji-user-binding.spec.tsruns the whole user switch in ONE page load (client-side router nav only) so the singleton-retention leak is actually exercised, and the second user joins the first user's server instead of creating one (in-session "create a second server" leavessourceIdempty and the submit disabled).
Don't strand signed-out mobile users on a logged-out dashboard [auth] [mobile] [routing]
- Trigger:
App.ngOnInitspecial-cased mobile — signed-out visitors landing on/or/dashboardwere kept on/dashboard(the "login form has no mobile chrome" rationale), so mobile users got a logged-out dashboard and never saw a login screen on startup. - Rule: decide startup routing for signed-out users with the platform-agnostic pure rule
resolveUnauthenticatedStartupRedirect(currentUrl)(auth-navigation.rules.ts) — non-public routes →/login(with safereturnUrl), public routes (/login,/register,/invite/...) → stay; do not branch onisMobile()here. - Why: the mobile exception directly contradicted the product expectation ("greet signed-out users with the login screen"); the login form already links to register, so there is no dead-end to avoid.
- Example: unit
auth-navigation.rules.spec.ts(resolveUnauthenticatedStartupRedirect('/dashboard') === { path:'/login', queryParams:{} }); e2ee2e/tests/mobile/mobile-login-on-startup.spec.tssets a 390×844 viewport before navigating (soViewportService.isMobileis true at bootstrap) and asserts/dashboardand/both land on/login.
"Shared from your device" must gate on local bytes, not uploader user id [attachments] [multi-device]
- Trigger: a second device of the same user showed "Shared from your device" and hid the download affordance for a file uploaded from another device —
isUploader(attachment)returneduploaderPeerId === currentUserId, butuploaderPeerIdis the user id (set tocurrentUser.idinpublishAttachments), so it is true on every device of the uploader, including ones that only synced metadata. - Rule: key the sharing/ownership UI off whether this device holds the bytes, not who uploaded it — use
isSharingFromThisDevice(attachment, currentUserId)(=isUploaderUser && deviceHasLocalCopy) fromattachment-sharing.rules.ts;deviceHasLocalCopy=available+ blobobjectUrl, or a non-emptysavedPath/filePath(synced metadata strips local paths, so it correctly reads as "no copy"). - Why: same-user devices do not P2P with each other and sync only via
account_sync(which stripsfilePath/savedPath), so the second device legitimately has no bytes; claiming ownership blocked the only path to view/download. For the regression to even be reachable in e2e,account_sync'schat-sync-batchhad to start carrying theattachmentsmap (it previously dropped attachment metadata entirely) viapushSavedRoomMessagesViaAccountSync(..., loadAttachmentMetas). - Example: unit
attachment-sharing.rules.spec.ts(isSharingFromThisDevice({uploaderPeerId:'u1', available:false}, 'u1') === false); e2ee2e/tests/chat/multi-device-attachment-sharing.spec.tsuploads on device A then logs device B in afterward so theaccount_sync_peer_onlinefull-state push delivers the attachment, then asserts device B shows a Request button and no "Shared from your device".
Generate Android brand icons from the source mark; guard against stock Capacitor placeholders [mobile] [android] [assets]
- Trigger: the Android app shipped the default Ionic/Capacitor launcher icon (and a white adaptive background) because no brand icon was ever generated into
toju-app/android/app/src/main/res/. - Rule: regenerate launcher + splash from
images/icon-new-rounded.pngwithnpm run cap:assets:android(tools/generate-android-app-icons.mjs, usessharp), set the adaptive background to brand purple#4A217A(never#FFFFFF), and have the adaptive icon reference@mipmap/ic_launcher_foregroundPNGs (delete the stockdrawable-v24/ic_launcher_foreground.xmlvector).cap:syncis not needed — these live in the native project, notwebDir. - Why: a native launcher icon can't be asserted through a browser, so the regression proof is a hash guard:
mobile-android-launcher-icon.rules.tsrecords the SHA-256 of every stock placeholder and the tests fail if any density still matches one. Pixel checks (purple ring + white-cat centre) confirm the brand mark actually rendered. - Example:
findStockCapacitorResources(hashByFile)must return[]; unitmobile-android-launcher-icon.rules.spec.ts+ e2ee2e/tests/mobile/android-app-icon.spec.ts(deterministic fs/pixel checks, no emulator).
Bind chat attachments to a pre-allocated message id, never by matching content [attachments] [chat] [mobile]
- Trigger: caption-less media (videos/images sent with no text) grouped onto the message bubble above and left an empty message below on Android —
ChatMessagesComponentdispatchedsendMessagewithout an id, then asetTimeoutre-discovered the message byentry.content === content(always''for attachment-only sends) and calledpublishAttachmentson it. - Rule: pre-allocate the message id in the component (
planChatMessageSendinchat-message-send.rules.ts), dispatch it viaMessagesActions.sendMessage({ id, ... })(effect usesid ?? uuidv4()), and bind attachments to that exact id withpublishAttachments(id, files)— never re-find the message by content/timing. - Why: empty content is shared by every attachment-only message, so content matching picks the newest match and races the async create-effect; on Android the create latency exceeds the old 100 ms timer, so the file binds to a stale sibling. The race is invisible on fast desktop browsers, so the deterministic regression proof is the unit test that asserts the dispatched action id equals the attachment-binding id, not an e2e timing game (see the "don't bump E2E timeouts for sync flakes" lesson).
- Example:
planChatMessageSend(...).attachmentBinding.messageId === plan.action.idenforced inchat-message-send.rules.spec.ts; behavioral guard ine2e/tests/chat/attachment-only-message-grouping.spec.ts(proves the id flows component→effect→attachment by requiring each caption-less attachment to render in its own bubble).
Attachment file persistence must be platform-agnostic, not Electron-only [attachments] [persistence] [mobile]
- Trigger:
AttachmentStorageServicetalked only towindow.electronAPI, socanWriteFiles()returnedfalseon Android (Capacitor) and in the browser — no bytes were ever persisted there, and after restart/logout-login the uploader hit "Your original upload could not be found on this device" / "no peer with this file". - Rule: keep the path/bucket layout in
AttachmentStorageServicebut delegate raw IO to a pluggableAttachmentFileStoreselected byPlatformService— Electron disk, CapacitorDirectory.Data(lazy-loaded, inline media viaconvertFileSrc), and a per-user IndexedDB vfs for the browser with a finitemaxPersistableBytescap; gate transfer persistence oncanStreamToDisk()/canPersistSize()so the cap degrades gracefully. - Why: the browser e2e harness can't test native disk, but the browser IndexedDB store is real persistence, so a single-client send →
page.reload()→ reopen-room test proves the whole persist/restore orchestration with no peer connected. - Example:
attachment-file-store.ts+{electron,browser,capacitor}-attachment-file-store.ts;e2e/tests/chat/local-attachment-persistence.spec.tswaits for both byte records (vfs) andattachmentsrecords withsavedPath(summed across allmetoyou/metoyou::<user>DBs, since an empty anonymous-scope DB exists) before reloading.
Never count duplicate chunks toward transfer progress, and never finalize on byte counters [attachments] [webrtc]
- Trigger: P2P attachments arrived corrupt everywhere ("only the first bytes") because concurrent auto-download triggers double-requested a file, the sender streamed it twice, and the receiver counted duplicate chunk deliveries toward
receivedBytes— inflating it pastsize, which both dropped the remaining chunks (post-Security guard) and passed thereceivedBytes >= sizefinalize shortcut over a sparse buffer. - Rule: in chunked transfer receivers, ignore an already-buffered chunk index entirely (no progress update), use dense buffers, and finalize only when every chunk index is present — never use byte totals as an alternative completion signal; dedupe streams on the sender per
(messageId, fileId, peerId). - Why: byte counters lie as soon as any duplicate, retry, or concurrent stream exists, and sparse-array
every/someskip holes, so "looks complete" checks silently pass on partial data (same trap as the custom-emoji sparse-array lesson). - Example:
handleFileChunk/finalizeTransferIfCompleteinattachment-transfer.service.ts; multi-chunk e2e coverage viaexpectMessageImageContentSha256ine2e/tests/chat/chat-message-features.spec.ts(single-chunk files cannot catch assembly bugs — test with >64 KiB payloads).
Don't bump E2E timeouts for sync flakes - gate on presence and read server logs [testing] [realtime]
- Trigger: a multi-client chat-sync E2E flaked on "message not visible" and the first instinct was to raise
toBeVisibletimeouts or add waits; the user correctly rejected this ("it's not a timeout issue"). - Rule: when a cross-user E2E assertion flakes, first gate the assertion on an observable precondition (peer visible in the members panel), then diff the signaling-server logs of a passing vs failing run (
joined server,user_joined,user_left,Removing dead connection) before touching any timeout. - Why: the flake was a server race —
identify+join_serverarriving in one TCP segment were processed concurrently, the join was dropped as unauthenticated, and room membership silently vanished; no timeout can fix a message that is never broadcast. Fixed by serializing per-connection message handling inserver/src/websocket/handler.ts. - Example: failing run showed one
joined serverfor Ludde thenuser_lefton sibling-client close; passing run showed two.expectServerPeerVisible(page, displayName)ine2e/helpers/multi-device-session.tsis the presence gate.
When renaming an Angular route, sweep every navigate/url-match/doc reference [routing]
- Trigger: the find-servers route was renamed
/search→/serversinapp.routes.ts, butservers-rail.component.tsstill calledrouter.navigate(['/search'])(leave-server) and matchedstartsWith('/search')for the user-bar visibility signal, throwingNG04002: 'search'on leave and never showing the user-bar on the discovery page. - Rule: after changing a
path:inapp.routes.ts, grep the whole repo for the old literal (/search) across*.ts/*.html(router calls,startsWith/url-match signals) and docs (docs-site,.agents/skills/playwright-e2e/SKILL.mdroute tables, domain READMEs) and update them all in the same change. - Why:
router.navigateto a non-existent path raisesNG04002and aborts navigation, and stalestartsWithmatches silently break route-derived UI state — neither is caught by the build (string literals) and there was noservers-railspec to catch it. - Example: fixed
isOnServers/router.navigate(['/servers'])inservers-rail.component.{ts,html}; canonical post-leave/discovery route is/servers(FindServersComponent), matchingDashboardComponent'srouter.navigate(['/servers']).
Server discovery must fan out across all endpoints and self-heal on 404 — never hardcode a host capability blocklist [server-directory]
- Trigger: the dashboard "Popular Servers" and
/serversdiscovery view were empty for fresh users until they typed a search. The first fix added a staticDISCOVERY_UNSUPPORTED_HOSTSblocklist (signal.toju.app/signal-sweden.toju.app) that short-circuited discovery to[]; the production hosts later shipped the/featured+/trendingroutes (verifiedcurl→ 200 with servers), so the stale blocklist kept blocking exactly the default endpoints a fresh account has while ungated search still surfaced them. - Rule: discovery (
getFeaturedServers/getTrendingServers) must fan out acrossgetSearchableEndpoints()withforkJoin+deduplicateById(mirroring all-endpoint search), and detect capability at runtime — on a404from/api/servers/{featured,trending}, fall back per-endpoint to the publicGET /api/serverslisting (fetchPublicServerListForDiscovery) instead of returning[]. Do not maintain a hardcoded list of hosts that "don't support" a route; it goes stale silently and the build can't catch it. - Why: legacy servers resolve
/featuredas/servers/:idand answer 404, so a 404→fallback keeps the default view populated everywhere without a blocklist; the empty-query view renders discovery sections (not search results), so any divergence between discovery and search makes it look broken while search works. - Example:
fetchDiscoveryFromEndpoint+fetchPublicServerListForDiscoveryinserver-directory-api.service.ts;e2e/tests/servers/server-discovery-default.spec.tsproves a fresh account sees Popular Servers without searching AND that route-intercepting/featured+/trendingto 404 still populates it via the fallback.
Server registration needs ownerPublicKey: oderId || id, and must not be fire-and-forget [server-directory] [rooms]
- Trigger: creating a server appeared to work (the creator landed in the room view) but the server didn't exist on the backend — invite-link creation and search both 404'd.
createRoom$sentownerPublicKey: currentUser.oderIdwith no fallback; on restored sessionsoderIdcan be falsy (identify still works because it falls back toid), soPOST /api/serversreturned400 Missing required fields, and the.subscribe()swallowed the error whilecreateRoomSuccessfired regardless. - Rule: resolve owner identity as
oderId || ideverywhere it's required (the server rejects an emptyownerPublicKey), and giveregisterServer().subscribe()anerrorhandler so a failed registration is never silent. - Why: verified against the live server — authed POST with a truthy
ownerPublicKey→ 201; authed POST with an empty one → 400; the swallowed 400 is exactly what produces a "ghost" room the creator can enter but no one can find. - Example:
buildServerRegistrationPayload(room, currentUser, normalizedPassword)intoju-app/src/app/store/rooms/server-registration.rules.ts, used byRoomsEffects.createRoom$.
Identify must fall back to the legacy session token, not only the new credential store [realtime] [authentication]
- Trigger: the multi-signal-server auth refactor changed
resolveCredentialForSignalUrlto read onlySignalServerCredentialStoreService; sessions restored from disk (and logins whereuser.homeSignalServerUrlis unset) have an empty credential store, soidentifywas skipped on every signal server ("Skipping identify because no session token is available") and users appeared alone — no presence, no peers, sent messages visible only to themselves. E2E never caught it because every e2e flow does a fresh register/login that writes the credential store directly. - Rule: when resolving the identify credential for a signal URL, prefer the per-signal credential but fall back to the legacy
AuthTokenStoreServicetoken reconstructed with the current home user'sid/displayName; never gateidentifysolely on the new credential store. - Why:
persistSessionTokenalways writes the legacymetoyou.authTokensstore on login, but the per-signal credential store is only populated on fresh login (with aloginResponse) or successful migration/provisioning — so on reload it can be empty while a valid session still exists. - Example:
resolveSignalIdentity(credential, legacyTokenEntry, homeUser)insignal-server-credential-resolution.rules.ts, wired throughSignalServerAuthService.resolveCredentialForSignalUrl(which now passesthis.authTokenStore.getTokenEntry(httpUrl)and ahomeUsercarryingid). Test cross-user behavior via a session-restore path, not just fresh login.
Keep the per-signal-URL identify credential resolvable from the store [realtime] [authentication]
- Trigger: after the multi-signal-server auth refactor,
SignalingManager.getLastIdentifywas switched togetIdentifyCredentialsForSignalUrl, which only read an in-memory cache populated afteridentify()ran; a freshly (re)connected socket then emittedjoin_serverbefore any identify and users silently never appeared in the presence roster (almost all multi-user e2e tests timed out waiting for the peer'sroom-user-card). - Rule:
getIdentifyCredentialsForSignalUrlmust fall back to resolving the credential from the credential store so a new socket'sonopenre-identifies before it re-joins; never restrict it to only the in-memory identify cache. - Why: the server drops
join_server/view_serveron any unauthenticated connection, so an identify-less join is lost with no error and recovery only happens on a later reconnect (often beyond the 20s test timeout). - Example: server log showed
join_server authed=false ... display=Userdropped, thenUser identified: Aliceon a different connection but noAlice joined server; fixed insignaling-transport-handler.tsby resolving viadependencies.resolveCredential(signalUrl)when the cache is empty.
Store clientInstanceId in sessionStorage not localStorage [realtime] [multi-device]
- Trigger: same user logged in on two tabs, browsers, or synced profiles sees alternating "Disconnected from signaling server" and no cross-device chat/voice sync.
- Rule: persist
metoyou.clientInstanceIdinsessionStorage(one id per tab/window) and clear any legacylocalStoragecopy on first read. - Why: server identify evicts stale sockets with the same
(oderId, connectionScope, clientInstanceId)tuple; a shared localStorage id makes each client kick the other in a reconnect loop. - Example:
ClientInstanceService.getClientInstanceId()writes tosessionStorage; two tabs get different ids and stay connected simultaneously.
Revalidate IndexedDB scope without reinitializing on every read [persistence] [performance]
- Trigger:
DatabaseService.ensureReady()calledinitialize()before every delegated read/write to fix user-scope races. - Rule: cache the last validated
metoyou_currentUserIdand only re-run backend initialization when that scope changes or an in-flight initialize completes with a different scope. - Why: per-operation revalidation fans out across ban lookups, room loads, and message reads, causing channel/chat UI to stay blank until repeated server clicks eventually win the race.
- Example:
ensureReady()returns immediately whenisReady()andvalidatedUserScopestill matchgetStoredCurrentUserId().
Restore local user scope before protected writes [authentication] [persistence]
- Trigger: a logged-in in-memory user can create rooms or messages after
metoyou_currentUserIdwas cleared by a late session-expired path. - Rule: before protected local persistence or server-directory actions, restore
metoyou_currentUserIdfrom the current user and avoid treating a live current user as unauthenticated. - Why: otherwise rooms/messages fall into the anonymous IndexedDB scope, and route checks redirect to login even though NgRx still has the authenticated user.
- Example:
MessagesEffects.sendMessage$,RoomsEffects.createRoom$, and server-directory create/join components callsetStoredCurrentUserId(currentUser.id)before writing or joining.
Persisted local user state still requires a session token [authentication] [signaling]
- Trigger: Users appear logged in from local storage but cannot see peers online or send chat after session-token auth shipped.
- Rule: before connecting signaling or loading rooms for a persisted user, require a non-expired token in
metoyou.authTokens; redirect to/loginonSESSION_EXPIRED,auth_required, orauth_error. - Why: WebSocket
identifyis skipped without a token, sojoin_server, RTC relay, and presence never establish even though the profile exists locally. - Example:
hasValidPersistedSession()inauth-session.rules.tsfromloadCurrentUser$.
Declare MODIFY_AUDIO_SETTINGS for Android WebRTC mic capture [mobile] [android]
- Trigger: Android users accept the microphone prompt but voice calls and channels still fail to join.
- Rule: include
android.permission.MODIFY_AUDIO_SETTINGSintoju-app/android/app/src/main/AndroidManifest.xmland preflight Capacitor capture throughMobileMediaService.ensureVoiceCapturePermissions()beforegetUserMedia. - Why: Capacitor's
BridgeWebChromeClient.onPermissionRequestrequestsRECORD_AUDIOandMODIFY_AUDIO_SETTINGStogether; if the latter is undeclared, the combined grant is treated as denied even after the user taps Allow. - Example:
ANDROID_REQUIRED_MANIFEST_PERMISSIONSinmobile-android-manifest-permissions.rules.ts.
Do not override Tailwind with box-sizing inherit [mobile] [css]
- Trigger: mobile pages still overflow horizontally until devtools disables
*, *::before, *::after { box-sizing: inherit }in global styles. - Rule: in
src/styles.scsskeepbox-sizing: border-boxon the universal selector (matching Tailwind preflight); never replace it withinheritfromhtml. - Why:
inheritoverrides preflight and some nested component hosts resolve tocontent-box, sow-fullplus padding becomes wider than the parent — especially visible on the mobile dashboard beside the servers rail. - Example:
src/styles.scss@layer baseuniversal rule usesborder-box, notinherit.
Use the app-shell servers rail for mobile discovery pages [mobile] [layout]
- Trigger: patching
min-w-0/overflow-x-hiddenon the dashboard (or find-people/find-servers) while the page still renders wider than the phone beside an embedded servers rail. - Rule: on mobile discovery routes (
/dashboard,/people,/servers, …) show the globalapp.htmlservers rail and render the page full-width inappWorkspace; keep embedded swiper+rail stacks only for chat/DM/call routes (shouldShowMobileAppServersRailinmobile-shell-layout.rules.ts). - Why: nesting a second rail+Swiper stack inside
router-outletfights the shell flex width and content keeps sizing to intrinsic width, clipping cards and inputs on every viewport. - Example:
hideAppServersRail()inapp.html+ dashboardpageContentonly (no local<app-servers-rail>).
Defer attachment blob hydration on Electron startup [attachments] [electron]
- Trigger: fixing inline attachment display by eagerly calling
tryRestoreAttachmentFromLocal()for every persisted attachment duringinitFromDatabase(). - Rule: load attachment metadata at startup, but hydrate blob URLs only for the watched room on demand; read disk files through chunked IPC (
readFileChunk) and yield between chunks/attachments so large images never block the renderer. - Why: restoring every saved attachment as a single base64 round-trip plus synchronous
atob()can freeze Electron for seconds even after the shell paints. - Example:
runInitFromDatabase()stops atloadFromDatabase();restoreLocalAttachmentsForRoom()hydrates lazily viarestoreAttachmentBlobFromDiskPath().
Lazy-load Capacitor modules on Electron/desktop [mobile] [electron]
- Trigger: adding mobile facades that statically import Capacitor adapters or
@capacitor/*plugins into shared Angular services used by the desktop app. - Rule: keep web/electron shells on web adapters synchronously and load Capacitor adapters/plugins only through dynamic
import()afterruntime === 'capacitor'— never top-levelimport '@capacitor/...'in code reachable fromapp.ts/DirectCallService. - Why: bundlers evaluate static Capacitor imports during Electron startup, which can freeze the renderer before first paint even when runtime detection would have chosen the web adapter.
- Example:
resolveMobileAdapter()inmobile-capacitor-adapter.rules.tsplus asynccapacitor-plugin-loader.ts/loadMetoyouMobilePlugin().
Use the upgrade transaction during IndexedDB schema migrations [persistence] [browser]
- Trigger: bumping
BROWSER_DATABASE_VERSIONand opening existing stores viadatabase.transaction(...)insideonupgradeneeded. - Rule: during
onupgradeneeded, reuseevent.transaction.objectStore(name)for existing stores and only calldatabase.createObjectStorefor missing ones — never start a second transaction while the version-change transaction is active. - Why: nested transactions abort the upgrade,
authenticateUserstorage prep fails, and login/register navigates beforesetCurrentUserso DM routes throw "Cannot use direct messages without a current user." - Example:
ensureObjectStoreDuringUpgrade(database, upgradeTransaction, 'messages')inbrowser-database-schema.ts.
Wait for authenticateUser storage prep before post-login navigation [authentication] [browser]
- Trigger: dispatching
UsersActions.authenticateUserfrom login/register and immediately callingrouter.navigate(...). - Rule: wait for
setCurrentUserorloadCurrentUserFailure(e.g.waitForAuthenticationOutcome(actions$)) before navigating toreturnUrlor/dashboard. - Why:
authenticateUser$prepares per-user IndexedDB asynchronously; early navigation renders DM/shell routes before the current user exists in the store. - Example:
await firstValueFrom(waitForAuthenticationOutcome(this.actions$))inregister.component.tsandlogin.component.ts.
Use dense arrays for chunked transfer buffers [custom-emoji] [webrtc]
- Trigger: chunked P2P asset assembly marks a transfer complete after the first chunk because
array.some()skips sparse holes created bynew Array(total). - Rule: initialize chunk buffers with
Array.from({ length: total }, () => undefined)(or another dense initializer) before usingsome/every/filterto detect completion. - Why: a single assigned slot in a sparse array makes
.some((chunk) => !chunk)return false, so multi-chunk custom emoji transfers are dropped and peers never receive uploaded images larger than one chunk. - Example:
CustomEmojiService.receiveTransferStartstoreschunks: Array.from({ length: total }, () => undefined)instead ofnew Array(total).
Route custom emoji right-click through the native context menu [custom-emoji] [ux]
- Trigger: adding a second emoji-specific context menu beside
NativeContextMenuComponent, or attaching handlers only to<img>nodes. - Rule: mark emoji hosts with
data-custom-emoji/data-custom-emoji-libraryplusdata-custom-emoji-id, letNativeContextMenuComponentown add/remove actions, and use a capture-phasepreventDefaultso Electron/browser image menus do not override them. - Why: the shell context menu already intercepts every image right-click; duplicate menus fight each other and button/div wrappers miss img-only handlers.
- Example: reaction pills and picker buttons carry the data attributes;
resolveCustomEmojiContextMenuTarget()opens Add to emoji library / Remove from emoji library from the global menu.
Separate known emoji assets from saved library [custom-emoji] [ux]
- Trigger: syncing remote custom emoji directly into the picker/library when it is first seen in chat.
- Rule: store remote emoji as known renderable assets, but only show them in the user's picker after an explicit save action such as right-clicking the rendered emoji.
- Why: users need messages to render, but they should control which seen emoji become part of their local emoji library.
- Example:
CustomEmojiService.emojisfilters to saved emoji, whilefindEmoji(id)can still resolve unsaved known assets for message rendering.
Chunk custom emoji assets over data channels [custom-emoji] [webrtc]
- Trigger: sending uploaded custom emoji image data through a single
custom-emoji-fullpeer event. - Rule: stream custom emoji assets as a metadata envelope plus bounded
custom-emoji-chunkevents; use buffered sends for back-pressure, but never rely on buffering to make oversized messages safe. - Why: a single base64 data URL can exceed browser SCTP message limits and fire
RTCDataChannel.onerror, breaking the app-wide chat channel. - Example: send
{ type: 'custom-emoji-full', customEmojiTransfer, total }, thencustom-emoji-chunkevents with smalldataslices.
Re-clear visible notification channels after recompute [notifications] [startup]
- Trigger: fixing startup unread badges by only changing read-marker writes or initial hydration.
- Rule: also check later
loadMessagesSuccessandsyncMessagesrecomputes, and re-clear the focused visible channel after applying derived unread counts. - Why: the startup-selected server can load or sync messages after it was marked read, reintroducing a channel unread badge even though the user is viewing that channel.
- Example:
NotificationsService.refreshRoomUnreadFromMessages(...)should clearactiveChannelIdforcurrentRoomafter recalculating counts from a startup message batch.
Disambiguate nested chat cards [chat] [ui]
- Trigger: removing a visual treatment from chat history when a system message has both an outer row wrapper and an inner pill/card.
- Rule: preserve the intended inner timeline pill unless the user explicitly targets it; render system messages outside the themed
chatMessageBubblewrapper and keepdata-message-idoff direct childdivs. - Why: PM call-started history should stay as a compact centered pill, while theme CSS such as
app-chat-message-item > div[data-message-id]can turn the full-width row around it into the unnecessary card. - Example: In
chat-message-item.component.html, keepdata-testid="chat-system-message"withrounded-full border bg-secondary/45, putappThemeNode="chatMessageBubble"only on the non-system branch, and place[attr.data-message-id]on the nested pill instead of the system row wrapper.
Use terminal Vitest when the test tool hangs [testing]
- Trigger: VS Code test execution stays at "Starting test run..." without producing Vitest output.
- Rule: run the focused spec through the terminal with
cd toju-app && npx vitest run <spec-path>and report the direct Vitest result. - Why: the test integration can hang before starting the runner, while the terminal Vitest command returns quickly and gives actionable failures.
- Example:
cd toju-app && npx vitest run src/app/domains/game-activity/application/game-activity.service.spec.ts.
Do not add fake chrome around screenshots [website] [design]
- Trigger: wrapping a real product screenshot in decorative titlebar/window chrome or placing oversized marketing headings beside copy without checking overlap.
- Rule: use the screenshot's existing frame when it already includes app chrome, and top-align large heading/copy columns with explicit readable widths.
- Why: duplicated chrome makes CTA/product previews look broken, and bottom-aligned large headings can cover accompanying text on the marketing site.
- Example:
website/src/app/pages/home/home.component.htmlshould render the screenshot directly;host-sectionshould use top-aligned heading and.host-section-copycolumns.
Prefer npm run lint:fix over hand-fixing lint/format [verification] [lint] [tokens]
- Trigger: about to manually re-indent, reorder imports, or tweak Prettier/ESLint-fixable style after seeing lint failures.
- Rule: from repo root run
npm run lint:fix(format+sort:props+eslint . --fix); only hand-edit remaining non-fixable errors. - Why: manual style fixes burn turns and tokens and often miss what the project script already auto-corrects.
- Example: after code changes →
npm run lint:fix→ if exit 0, do not also rewrite imports by hand.
Verify lint exits 0 before claiming done [verification]
- Trigger: about to report a task as complete after running tests but skipping ESLint.
- Rule: run
npm run lint:fixfrom the repo root (ornpm run lintafter fixes) and confirm exit code 0 before any "done" claim. - Why:
npm run testonly runs the toju-app Vitest suite — it doesn't cover the server, Electron, or website packages. ESLint (flat config ineslint.config.js) is the universal check across every package; type-style violations slip through tests and break Gitea Workflows for the next agent. - Example:
npm run lint:fix && echo OK— only claim done after seeingOK. For Electron type errors specifically, also confirmnpm run build:electronsucceeds (it invokestsc -p tsconfig.electron.json).
Use blob URLs for inline attachment previews [attachments] [electron]
- Trigger: receiving users see broken image icons or video players that never start, but "Download" saves a valid file.
- Rule: never bind
attachment.objectUrltofile://URLs for chat<img>,<video>, or<audio>— always create ablob:URL from the bytes on disk or in memory; keepsavedPath/filePathfor IPC download/open only. - Why: Electron runs with
webSecurity: true, so renderer pages cannot load arbitraryfile://app-data paths even when CSP allowsfile:; IPC download still works because it reads the path in the main process. - Example:
ensureInlineDisplayObjectUrl()inAttachmentPersistenceService, andURL.createObjectURL(blob)infinalizeTransferIfComplete/handleDiskFileChunkinstead ofgetFileUrl(savedPath).
Resolve Electron drag-and-drop file paths with webUtils [attachments] [electron]
- Trigger: large videos play after drag-and-drop upload, but after restart the uploader sees a peer-download error even though they sent the file from disk.
- Rule: when accepting dropped or pasted files in Electron, call
webUtils.getPathForFile(file)from preload (getPathForFileonelectronAPI) and annotate theFilebeforepublishAttachments; never rely onFile.pathin the renderer. - Why: Chromium removed direct
File.pathaccess in modern Electron; withoutgetPathForFile, large uploads only exist as in-memory blobs and cannot be copied into app data for reload playback. - Example:
annotateLocalFilePath(file, { getPathForFile: electronApi.getPathForFile })inChatMessageComposerComponent.addPendingFiles.
Preserve uploader local attachment paths across sync [attachments] [persistence]
- Trigger: large Electron uploads play from
filePathafter send, but after reload the uploader sees "The connected peers do not have this file right now" and must P2P-download their own file. - Rule: never persist synced attachment metadata with
filePath/savedPathstripped — merge with stored local paths, finish attachment DB init before applying sync batches, and try local disk restore before sendingfile-requestto peers. - Why: P2P sync intentionally omits local-only paths; a startup race can overwrite the uploader's saved
filePathwithnull, and large videos (>10 MB) are not auto-copied to app data so only the original path can restore playback. - Example: copy large Electron uploads into app-data on
publishAttachments,mergeAttachmentLocalPaths(incomingMeta, storedRecord)inpersistAttachmentMeta,await persistence.whenReady()inregisterSyncedAttachments, andtryRestoreAttachmentFromLocal()before anyfile-request.