Chat internals
Chat is a profile-scoped workflow built from small ES modules rather than one panel controller. The active conversation lives instate.chatHistory, while durable thread metadata, messages, custom personas, and deletion records use separate storage keys. UI work must preserve four invariants:
- switching profiles or conversations must not let late async work mutate the new active thread;
- an unreadable encrypted record must fail closed instead of being replaced with an empty chat;
- creating a conversation must never delete an older one; and
- drafts are device-local, while sent messages and personas can participate in backup and optional Sync.
Ownership map
chat-loader.js keeps this graph cold until the user opens chat, follows a chat deep link, or enters a chat-first onboarding path. Feature modules communicate through explicit configuration callbacks where a direct import would create a cycle.
Composer drafts
chat-composer.js grows the textarea to its CSS maximum, updates the send-button state, and binds the visible input to the active { profileId, threadId }. chat-draft-storage.js debounces writes by 300 ms under:
crypto.js’s sensitive-key allowlist. With local data protection enabled and unlocked, encryptedSetItem() stores an AES-GCM envelope; otherwise the wrapper stores plaintext. Drafts are deliberately excluded from Sync, single-profile export, database backup, auto-backup, and folder backup.
Thread switches save the visible draft before changing state and restore the destination draft afterward. A monotonic restore request ID plus profile/thread checks prevents a slow decrypt from writing an old conversation’s text into the new composer. Sending clears the draft through the ordered write chain, so an already-running debounced save cannot resurrect it.
Send and response flow
Direct providers and local agents share the enabled context snapshot. CLI transport, local structured tools, and the personal-gateway boundary are documented in CLI agent providers. Do not assume a CLI exposes shell access or that a gateway receives the local tool bridge. For a normal turn,chat-send.js:
- snapshots the active thread and provider state;
- appends and persists the user message;
- assembles
CHAT_SYSTEM_PROMPT, enabledbuildLabContext()output, web/private-mode hints, and the active persona; - sends up to the last 30 eligible conversation messages;
- streams into a thread-bound DOM node and screen-reader status; and
- persists the final assistant message, usage, provider/model metadata, context disclosure, knowledge-base sources, and recommendation state.
CHAT_RESPONSE_MAX_TOKENS is 16,384. callChatAPIWithContinuation() can make up to two automatic continuation calls when provider metadata or the response shape indicates truncation. Usage from all parts is merged. A still-truncated result is persisted with truncated: true. Agent transports normalize their own completion and usage signals; do not assume the direct API continuation loop controls them.
Stopping aborts the active request and keeps a non-empty partial response with stopped: true. The regular action bar then offers Retry. Recommendations always persist collapsed; recNew supplies a finite attention cue only for the first or materially changed set of recommendation slots.
Generation ownership covers silent starts as well as streaming. isChatStreaming() releases stale generation state after a profile switch; late deltas, completion, persistence, and finally cleanup must remain bound to the originating generation. Thread loads, searches, project dialogs, summaries, and dropped-file reads also use profile/thread/revision checks. Failed persistence must not leave a successful-looking in-memory project or summary change.
Composer files and presentation
The plus menu routes readable supported image files into the message queue only if the active model declares image support. PDFs and other files go throughhandleImportInputChange() and normal reviewed import handlers. The browser captures dropped-file access during the drop event; if the OS/browser denies reading, a picker recovery dialog is shown rather than pretending the file was imported.
Chat original images are not recompressed or stripped of metadata. Queue limits are five images for direct providers, four for agents, and 18 MiB total. History retains bounded thumbnails, while full originals remain request-time data. CLI uploads additionally pass the authenticated Companion’s type/size validation and temporary-file cleanup. Preserve these distinctions in privacy copy.
Desktop resizing persists bounded device-local widths and updates the dashboard’s available width, including when the conversation rail changes. Compact mobile layouts do not inherit an unusable desktop width. Waiting phrases are decorative, not tool receipts. Sender avatars use the active profile image/initial or the selected persona/agent branding; default analyst labels are suppressed unless participant identity is needed. Treat all names, model labels, and image URLs as untrusted rendering inputs.
Markdown and model-output safety
markdown.js is the shared rendering boundary for ordinary chat, restored messages, discussion responses, streamed and saved summaries, Biology Score explanations, and EMF interpretations. Streaming chat text is assigned with textContent; only completed output is converted to HTML. Reopening or restoring a message runs its stored text through the same renderer again.
The renderer supports headings, paragraphs, emphasis, inline code, web and email links, blockquotes, lists, tables, horizontal rules, fenced code blocks, and untagged callouts. It does not accept raw HTML. Model-supplied tags and attributes are escaped before the renderer introduces its fixed tags and classes.
Markdown links allow only explicit http://, https://, and mailto: destinations. Generated external anchors receive noopener noreferrer; other schemes and whitespace-bearing destinations are neutralized. Inline code and completed anchors are protected from later auto-link processing, preventing links inside code and nested anchors.
Common encoded comparison signs such as <, >, ≤, and ≥ are normalized once before escaping so medical thresholds display as <, >, ≤, and ≥ without decoding arbitrary HTML. Quote nesting is capped before deeper prefixes are flattened, preventing pathological model output from exhausting the JavaScript call stack.
Bounded rendering and scroll ownership
Stored history is not truncated for rendering.chat-render-range.js initially exposes the last 120 records and expands backward in 120-record batches. Search can call revealChatRenderIndex() to expose a result outside the current window.
chat-scroll.js treats a position within 80 px of the bottom as “near latest.” Streaming follows the response only while the user remains near the bottom. If the user scrolls upward, new content does not steal their position; Jump to latest appears and receives a new-content cue. Prepending earlier history compensates for the changed scroll height so the visible messages remain anchored.
The transcript is an ARIA conversation log, but token-by-token DOM updates are not themselves a live region. chat-stream-status.js emits concise polite status changes such as responding, stopped, failed, or complete.
Conversations, search, edits, and forks
The thread index and message records are separate sensitive keys:chat-threads.js owns index writes. pruneOldThreads() is intentionally a compatibility no-op: retention is a user decision. If an existing index cannot be decrypted or parsed, writes and new-conversation creation are blocked so a corrupt or locked index cannot be overwritten with an empty one.
chat-thread-search.js filters names immediately, then debounces message-body search by 250 ms. It decrypts each thread on demand, caches results per active profile, displays at most 30 message matches, and delegates thread switching and bounded-range reveal back to the owners.
chat-message-edit.js permits Edit only for the latest visible user message, only while no response is streaming, and only when the message has no images. The inline editor resends through the normal send path, which trims history before that message and appends the revised text and replacement response. User messages do not repeat copy/retry actions; assistant response actions remain available.
Projects are not a separate database: normalized projectName (up to 60 characters) and pinned live on thread-index records. Creating a project creates a conversation in that project. Rename updates its member threads; delete clears membership without deleting the chats. Moving into a project removes separate pinning. Project choices have a non-drag menu alternative, and pointer drop destinations use fixed DOM attributes rather than parsing arbitrary dropped data. Save failures roll back mutations; profile changes invalidate pending dialogs.
A fork is non-destructive. Fork to new chat copies normalized history through the selected assistant response and creates a new thread with forkedFromThreadId and forkedFromMessageIndex. The new transcript shows a source notice and link when the original still exists.
Custom personas
Built-in personalities remain inCHAT_PERSONALITIES. Profile-scoped custom personas are stored as a normalized array under labcharts-{profileId}-chatPersonalityCustom; deletions use the timestamp map labcharts-{profileId}-chatPersonalityDeleted.
Each custom record has a stable custom_... ID, name, icon, editable promptText, createdAt, updatedAt, and optional hosted-app personaAgreement. The official getbased.health host requires the current agreement version before a custom persona can be saved or used. Self-hosted origins show a notice but do not require the checkbox.
chat-personality-storage.js owns encrypted persistence and the synchronous decrypted cache used by rendering. chat-personality-merge.js resolves Sync conflicts per persona by update timestamp and applies deletion tombstones. A selected custom ID that no longer exists falls back to the default personality. Each thread records its own selected personality; a new thread starts with the default.
Discussion rounds
Discussion state is persisted on the thread, including participant descriptors, the original personality, pending participants, and ended state. The active personality is locked when a discussion begins; the picker adds one participant at a time and discloses the immediate and future number of provider requests.chat-discussion-round-runner.js runs participants sequentially. Each response is a separate API request with its own usage metadata. The runner binds persistence to the origin thread, so switching conversations during a round cannot write into the new active history. Pause stores the unstarted participants; resume runs only the remainder. A failed participant persists a targeted error record so the UI can retry that participant or resume the remaining round without replaying successful responses.
Backup and Sync
Single-profile JSON export serializes normalized threads, messages, the selected personality, custom personas, and persona tombstones. Full database, auto, and folder backups preserve raw chat index/message values—including ciphertext that cannot be read during a locked backup—throughbackup-chat-storage.js. Drafts remain excluded.
Optional Cross-device Sync collects:
- the thread index (including project names, membership, and pinning) and per-thread messages;
- thread deletion tombstones;
- the active personality ID;
- custom persona records; and
- custom persona deletion tombstones.
updatedAt, with message count as an equal-timestamp tiebreaker. A short active-profile freshness lock protects just-edited local chat from stale inbound records. Persona records merge independently by ID and timestamp, so a conflict in one persona does not replace the whole array.
Import safety limits
chat-storage-safety.js normalizes untrusted import, backup, and Sync shapes before persistence. Current hard ceilings are 5,000 threads, 5,000 messages per thread, 50 custom personas, and 10 persisted thumbnails per message. These are defensive parsing limits, not normal UI retention policies. IDs, timestamps, flags, usage, fork/discussion metadata, image data URLs, and persona agreements are all normalized before use.
The curated single-profile JSON merge has a narrower compatibility limit: _importChatData() stops adding imported conversations when the destination index reaches 50. It does not prune or replace conversations already stored. Full database backup/restore preserves the raw chat records and is the appropriate migration path for a larger archive.