The same
dist/ serves all three: the Tauri shell on iOS and Android, and a browser as a Progressive Web App. The manifest, icons, and service worker are inert inside the shell, so one build covers every target. See Web App (PWA).praisonai-mobile is a Tauri 2 shell around a webview that runs the agent loop in-process. The whole conversation happens on the device.
Assistant messages and tool results render as plain text by construction — every row’s content is set through
textContent, never innerHTML. Model output, tool results, and summarised web pages cannot inject markup into the transcript. An untrusted string can appear on screen; it cannot become a script.Quick Start
1
Clone and install
2
Open a conversation
Tap a chat and the app dispatches the
chat route to a live screen — the transcript streams the agent’s reply token by token.3
Move around, then come back
Open Settings, scroll, tap About, then return. The chat screen is retained, so you land back where you were — same scroll position, same in-flight streaming.
4
Verify everything passes
check runs typecheck, boundaries, and test — the three gates that keep the two seams honest.The app ships on iOS 16+ and Android API 26+ via Tauri. The native shell handles safe-area insets, keyboard height, lifecycle, and the back gesture — see Native Shell.
Navigation
The app dispatches four screens. Only the chat screen survives navigation; the rest rebuild fresh on return. The top bar carries a Chats button next to New chat and Settings.When you scroll up in a conversation, open Settings, and come back, you land where you left off — the transcript keeps its scroll position and any in-flight streaming.
main.ts registers the retained chat screen with screens.nodes.set("chat", screen), so transition treats it as live and never rebuilds it.chats screen fetches a fresh snapshot each visit — session.list() plus repository.listUnreadable() — so a chat created since the list was last seen appears, and one deleted is gone. See History & Reopen.
New chat
New chat is a clean break: it stops any live run, mints a freshchat_id, and resets session and transcript state.
The launch conversation also gets a real
chat_id. createApp now passes chatId: deps.newChatId() into createRunController, so the very first conversation of every launch — the one the user never explicitly created via New chat — no longer goes to the engine as the literal "unassigned". Against an engine keying history by chat_id, that string once made every user’s first chat on every device one shared thread. See Architecture — Boot Order.Why It Matters
Every selling point is one line.How It Works
The app is six layers with two enforced seams: one for the agent framework, one for the UI shell. A streaming answer is one text row. Everydelta event advances the render state in place; the row’s textContent grows, but the DOM node is the same one from the start event to the end event.
Tool cards survive text on either side
A tool row is its own block; the text before and after it are two more. When the answer keeps talking after a tool result, the transcript’s block list is["text", "tool", "text"] — never ["text", "text"] with the tool row overwritten. Appending to the trailing text block extends only that block; it must not touch the block before it.
The tool row is not merged into an adjacent text block — its output belongs to a specific
tool_call and lives on its own row. Rendering that treats the transcript as one growing string cannot represent a tool that ran mid-answer.The transcript carries a jump-to-latest button, hidden by default and shown only when you have scrolled off the bottom of a streaming transcript. New tokens never yank you back down while you are reading; a tap on the button follows the stream again. See Follow & Jump.
Composer
The composer button toggles between Send and Stop, refuses empty input, and keeps your draft as data — so it survives a trip to Settings and autosizes as you type. Full detail on Composer Behavior.Alt+Enter and Shift+Enter are equivalent under enter-sends. On-screen keyboards without an Alt key are unaffected — they never send with Alt. Pinned by
"Alt+Enter inserts a newline under enter-sends, like Shift+Enter" and "a bare Enter still sends under enter-sends" in composer.test.ts.Best Practices
Your conversation is preserved
Your conversation is preserved
The chat screen is the only screen kept in the DOM when you navigate away. Its nodes stay, so scroll position and any streaming reply survive a trip to Settings and back.
Conversations are saved on this device
Conversations are saved on this device
When the on-device engine answers, the completed turn is written to the same session the chat list reads — so a conversation you had is there the next time you open the app.
Pick the engine that reports what your UI renders
Pick the engine that reports what your UI renders
The in-process
praisonai-ts engine executes tools but does not announce them, so capabilities.tools is false. Use remote-http when you need tool rows, approvals, or reasoning in the UI.Treat every stream event as typed
Treat every stream event as typed
Read events through the 11-event protocol rather than parsing prose. A tool call that silently failed still looks like a normal answer if you infer from text.
Never derive message indices client-side
Never derive message indices client-side
end.userIndex comes from the engine. A cancelled or errored turn is never persisted, so any index you compute from screen position drifts.Assert the composer label, not just its dataset
Assert the composer label, not just its dataset
data-action and textContent are set from the same render tick — both keyed on view.turn.phase === "streaming". Assert the label in tests, not just the dataset: a transposition of the ternary that assigns the label survives every test that only reads data-action, leaving a button that reads Send while it stops the run.Related
Architecture
How routes become screens, and where the session join lives.
Engines
The in-process engine, and how it persists a turn.
Native Shell
The Tauri shell — safe-area, keyboard, lifecycle, and back-gesture arbitration.
Web App (PWA)
The third target — installable, offline-capable, deployed to GitHub Pages.
Getting Started
Clone, run in the webview, and swap engines.
History & Reopen
The chats list and reopening a stored conversation.
Composer Behavior
Draft persistence, autosize, and the Enter policy.
Follow & Jump
Stick-to-bottom and jump-to-latest.
Route Focus
Where focus lands on every route change.

