Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion docs/mobile/DESIGN.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Muster mobile app: design

Status: phases 1 to 3 built, phase 4 (several computers, offline history and the outbox, native Markdown, tablets) built (issue #326); later phases planned below. This document is the plan of record for the phone app: how a phone reaches the
Status: phases 1 to 4 built, phase 5 (the v3 design, diff review with keep and undo, Stop) built (issue #326); later phases planned below. This document is the plan of record for the phone app: how a phone reaches the
person's desktop Muster from anywhere, how the traffic is protected, which stack the app uses, the screens, the phases and the
threat model. The acceptance cases live in [docs/spec/clauses/23-mobile.md](../spec/clauses/23-mobile.md).

Expand Down Expand Up @@ -341,6 +341,20 @@ column capped at 760 and a Needs-you inspector, and every orientation allowed (2
WindowManager module and is deferred. On the desktop: rows mapped once per version for all sessions and only for watched
chats, `remove` ops for deleted rows, and a fixed note on replies past the 64 KB item cap (23A to 23D).

**Phase 5: the v3 design and diff review (built).** The binding v3 design: tonal surfaces, the iOS type scale (nothing under
12 pt, prose 17 on 26, code 13 on 21), capsule buttons, three springs with Reduce Motion turning them into a 150 ms crossfade,
and glass (iOS 26 Liquid Glass through expo-glass-effect, a blur below it, a tonal surface on Android) on the tab bar, the
composer and toolbars (24A). Chats are two-line rows in Active and Earlier (24B); a turn's steps fold into one capsule with a live
line and the caret, and a finished run resolves into its summary (24C); Send turns into Stop (`chat.stop`, act only) (24D, 24M);
the composer shows its chips only while focused (24E); approvals lead with their question (24F); All clear, paired and
run-finished moments (24G, 24I); search sits in its own glass circle (24H). Diff review reuses the desktop's review domain:
`changes.list` (the chat's latest turn, as the changed-files card shows it), `changes.diff` (the desktop's own hunks with their
ids, named after their function, changed words marked, highlighted on the desktop with Shiki's JavaScript engine and the
desktop's themes as [dark, light] colour pairs, in pages of 400 lines), `changes.keep` and `changes.undo` (act only, access
checked again right before the write, a stale hash writes nothing) (24J to 24L). The phone shows a review ring and swipeable
files, an inline diff with one gutter and a glass hunk toolbar, and a split diff from 600 points wide (24N to 24P). The file
viewer, git actions and the terminal come next.

**Phase 3b: needs-you everywhere.** Push relay and generic pushes; Projects (tasks, runs, Inbox) read and act; a relay mounted
in Muster Server. Acceptance: an approval on the desktop produces exactly one generic push whose payload holds no chat text;
tapping it opens the card; approving from the card unblocks the run; revoking removes the push id.
Expand Down
200 changes: 199 additions & 1 deletion docs/spec/clauses/23-mobile.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/agent-app/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ All notable changes to Muster Agent. Each `## <version>` section becomes the not
- **Pair a phone (first part of the Muster mobile app).** Settings › Integrations › Phone shows a QR that pairs a phone with this computer through a relay you set; the phone and the computer talk end to end encrypted (Noise), so the relay only ever forwards ciphertext. A new phone is read only: it lists your chats, opens one and watches the turn stream, and sees what needs you. Choose Can act for it to send, steer, approve or decline one request and answer questions from the phone; approving for a whole session stays on the computer. Revoke ends its sessions at once. The pairing code works once and expires after 5 minutes, and pairing needs the system keychain (#326).
- **Start work from the phone (second part of the Muster mobile app).** A phone allowed to act can start a new chat in a folder or Project with the provider and model you pick, and attach photos and files: they travel through the same encrypted session in checked pieces (up to 20 MB each) and reach the agent as images and files, as if you had attached them on the computer. On the same Wi-Fi the phone now connects to the computer directly instead of through the relay, with the same encryption and the same checks, and falls back to the relay everywhere else; Settings › Integrations › Phone shows the addresses it uses and turns this off. Pairing on a phone is also steadier: a second scan or press while one is in flight no longer hides the check code, the pair screen is easier to read, and a `muster://pair` link opens the app and starts pairing.
- **A phone app that stays smooth while agents stream (third part of the Muster mobile app).** The computer now sends a phone only what changed in the open chat (the new end of a reply, a status, a new row), numbered so the phone notices a missed piece and reloads the chat; the phone's chat is a virtualized list that re-renders only the streaming row. A heartbeat notices a link that died while the phone slept and reconnects at once. Paths inside your folders reach the phone relative to the folder, and the computer caps how many requests and how large a request one phone can send.
- **Review changes and stop a turn from the phone (fifth part of the Muster mobile app).** A phone sees the files the chat’s latest turn changed and reads each diff, highlighted by this computer and sent in pages so a long file opens at once; a phone allowed to act keeps or undoes a whole file or one change and stops a running turn, with access checked again right before each write. The phone app also gets its new look: two-line chats grouped into Active and Earlier, a glass tab bar, steps folded into one capsule with a live line of what the agent is doing, Send that turns into Stop, and clearer approvals.
- **Several computers, offline reading and Markdown on the phone (fourth part of the Muster mobile app).** A phone can pair with more than one computer, switch between them from the computer pill or show all of them together, and each pairing keeps its own keys. The last chats it saw open with no connection, messages written offline go out once it reconnects (approvals always wait for the computer), and a computer that revokes the phone has its saved chats wiped from it. Replies render as native Markdown with code blocks you can copy, and tablets and landscape get a sidebar, chat and inspector. On this computer, the phone link now maps a streaming reply once for every phone watching (about 16 times less work while streaming), tells the phone when a message is removed, and says where a reply too long for the phone continues.
- **GitLab merge requests and Bitbucket pull requests.** A folder whose remote is on GitLab (gitlab.com, or your own GitLab once you save a token for its host) or Bitbucket Cloud now gets the same summary card rows, Git tab and PR tab as GitHub: list, create (pushing the branch first), view, comment and merge. GitLab is labelled merge request with `!` numbers; its pipeline jobs are the checks, and a failed job's log opens in the app and feeds Fix failing checks. Bitbucket build statuses (including Pipelines) are its checks; their logs open on Bitbucket for now. Sign in to GitLab with `glab auth login`, or save a token in Settings › Integrations › Git hosts; Bitbucket takes a username and app password, or an access token. Tokens are encrypted and sent only to the host they were saved for. Review threads, line comments, reviews, reviewer requests and mark-ready stay GitHub-only and say so on the other hosts (#329).
- **Android phones over USB in Devices.** A phone plugged in with USB debugging on is listed under Android with its maker and model and opens like an emulator, streamed through the same helper. A phone that has not allowed USB debugging yet is listed with how to fix it (accept the prompt on the phone). Install APK… on a connected phone picks an .apk and runs `adb install -r`. Without the Android SDK, an adb on PATH or from Homebrew is enough for phones; emulators still need the SDK. Muster never turns a USB phone off or rotates it (#363).
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-app/src/runtime/domains/mobile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export const mobileSeams: {secrets?: () => SecretStore | undefined; pairingTtlMs
maxRequestsInFlight?: number; requestGate?: Promise<void>;
/** Awaited after upload.finish checked a file and before it is staged. */
stageGate?: () => Promise<void>;
/** Awaited by changes.keep and changes.undo after their reads and before the access check and the write (phase 5). */
reviewGate?: () => Promise<void>;
/** Told how many characters the path scrubber reads (phase 4 cost receipts). */
onScrub?: (chars: number) => void} = {};
/** Requests one phone may have in flight at once, over all its sessions; more are refused at once (phase 3). */
Expand Down Expand Up @@ -380,6 +382,7 @@ export function createMobileDomain(context: DomainContext): DomainModule {
scrub,
map: (item: TimelineItem) => mapper!.map(item),
...(mobileSeams.stageGate ? {beforeStage: mobileSeams.stageGate} : {}),
...(mobileSeams.reviewGate ? {beforeReview: mobileSeams.reviewGate} : {}),
watch: (chatId: string | null, deltas?: boolean) => { session.watching = chatId; session.stream = chatId && deltas ? {chatId, seq: 0, sent: null, pending: []} : null; },
streamFrom: (chatId: string, items: MobileItem[]): number => {
const stream = session.stream;
Expand Down
115 changes: 114 additions & 1 deletion packages/agent-app/src/runtime/mobile/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
*/
import type {ApprovalData, Chat, PendingQuestion, ProviderInfo, Snapshot, TimelineItem} from '../../shared/protocol.ts';
import {device} from '../../shared/device-noun.ts';
import {ACTING_METHODS, MAX_SEND_ATTACHMENTS, MOBILE_METHODS, MOBILE_PROTOCOL_VERSION, MobileError, type MobileAccess, type MobileApproval, type MobileChat, type MobileChatStatus, type MobileItem, type MobileMethod, type MobileMethods, type MobileNeed, type MobilePlace, type MobileProvider, type MobileQuestion} from '../../shared/mobile/protocol.ts';
import {ACTING_METHODS, MAX_SEND_ATTACHMENTS, MOBILE_METHODS, MOBILE_PROTOCOL_VERSION, MobileError, type MobileAccess, type MobileApproval, type MobileChange, type MobileChat, type MobileReviewState, type MobileChatStatus, type MobileItem, type MobileMethod, type MobileMethods, type MobileNeed, type MobilePlace, type MobileProvider, type MobileQuestion} from '../../shared/mobile/protocol.ts';
import type {DomainContext} from '../domains/types.ts';
import type {PhoneUploads} from './uploads.ts';
import type {ReviewBaselineInfo, ReviewChange, ReviewMark} from '../../shared/domains/review-protocol.ts';
import {buildDiff, diffPage, hunkInfo, type BuiltDiff} from './diff.ts';

const MAX_ITEM_TEXT = 64 * 1024;
const OPEN_ITEMS = 200;
Expand Down Expand Up @@ -164,6 +166,49 @@ export interface BridgeContext {
map?(item: TimelineItem): MobileItem;
/** Test seam: awaited after upload.finish checked the file and before it is staged. */
beforeStage?(): Promise<void>;
/** Test seam: awaited after changes.keep or changes.undo read what they need and before the access check and the write. */
beforeReview?(): Promise<void>;
}

// ---------------------------------------------------------------------------
// Diff review (phase 5): the chat's latest turn, as the desktop's changed-files card shows it, through the review domain.

const STATUS: Record<string, MobileChange['status']> = {added: 'added', untracked: 'added', copied: 'added', deleted: 'deleted', renamed: 'renamed'};
const HASH = /^[0-9a-f]{40,64}$/;
/** A turn of the chat with a snapshot: the latest by default, or the one named (it must be this chat's). */
async function turnOf(invoke: DomainContext['invoke'], chat: Chat, runId?: string): Promise<ReviewBaselineInfo | null> {
const turns = await invoke('review.baselines', {chatId: chat.id});
if (runId) {
const named = turns.find(turn => turn.runId === runId);
if (!named || !named.treeSha || !named.folderId) throw new MobileError('not-found', 'That turn is not one of this chat’s.');
return named;
}
for (let index = turns.length - 1; index >= 0; index--) {
const turn = turns[index]!;
if (!chat.folderId || !turn.folderId || turn.folderId === chat.folderId) return turn.treeSha && turn.folderId ? turn : null;
}
return null;
}
const keptOf = (marks: readonly ReviewMark[], path: string) => new Set(marks.filter(mark => mark.path === path && mark.state === 'kept').map(mark => mark.hunkId));
function mobileChanges(files: ReviewChange[], marks: readonly ReviewMark[]): MobileChange[] {
const out = files.map((file): MobileChange => {
const kept = keptOf(marks, file.path).has('*');
return {path: file.path, ...(file.previousPath ? {previousPath: file.previousPath} : {}), status: STATUS[file.status] ?? 'modified', adds: file.adds, dels: file.dels,
...(file.binary ? {binary: true} : {}), state: kept ? 'kept' : 'review', afterHash: file.afterHash};
});
// A file undone as a whole is back to its text before the turn, so Git no longer lists it: the marks still do.
const listed = new Set(files.map(file => file.path));
for (const mark of marks) if (mark.hunkId === '*' && mark.state === 'undone' && !listed.has(mark.path)) { listed.add(mark.path); out.push({path: mark.path, status: 'modified', adds: 0, dels: 0, state: 'undone', afterHash: ''}); }
return out;
}
/** Built diffs of the last few files read, by file version: paging through one file builds it once. */
const built = new Map<string, BuiltDiff | null>();
function builtDiff(key: string, make: () => BuiltDiff | null): BuiltDiff | null {
if (built.has(key)) { const value = built.get(key)!; built.delete(key); built.set(key, value); return value; }
const value = make();
built.set(key, value);
if (built.size > 6) built.delete(built.keys().next().value!);
return value;
}

export async function needsList(invoke: DomainContext['invoke'], snapshot: Snapshot, scrub?: PathScrub): Promise<MobileNeed[]> {
Expand Down Expand Up @@ -320,6 +365,74 @@ export async function handle(ctx: BridgeContext, method: unknown, rawParams: unk
uploads.own(ref.id, file.chatId);
return {attachment: {id: ref.id, name: ref.name, mime: ref.mime, size: ref.size, kind: ref.kind}} satisfies MobileMethods['upload.finish']['result'];
}
case 'changes.list': {
const chat = await chatOf(id(p.chatId, 'chatId'));
const turn = await turnOf(ctx.invoke, chat);
if (!turn) return {runId: null, files: [], truncated: false} satisfies MobileMethods['changes.list']['result'];
const [changes, marks] = await Promise.all([ctx.invoke('review.changes', {folderId: turn.folderId!, baseline: {runId: turn.runId}}), ctx.invoke('review.marks', {runId: turn.runId})]);
return {runId: turn.runId, files: mobileChanges(changes.files, marks), truncated: changes.truncated} satisfies MobileMethods['changes.list']['result'];
}
case 'changes.diff': {
const chat = await chatOf(id(p.chatId, 'chatId')), path = text(p.path, 'path', 4096);
const from = p.from === undefined ? 0 : typeof p.from === 'number' && Number.isInteger(p.from) && p.from >= 0 ? p.from : -1;
if (from < 0) throw new MobileError('bad-request', 'Missing or invalid from.');
const turn = await turnOf(ctx.invoke, chat);
if (!turn) throw new MobileError('not-found', 'This chat has no changes to review.');
const baseline = {runId: turn.runId};
const changes = await ctx.invoke('review.changes', {folderId: turn.folderId!, baseline});
const change = changes.files.find(file => file.path === path);
if (!change) throw new MobileError('not-found', 'That file is not one of this turn’s changes.');
const [diff, marks] = await Promise.all([ctx.invoke('review.fileDiff', {folderId: turn.folderId!, path, baseline}), ctx.invoke('review.marks', {runId: turn.runId})]);
const base = {runId: turn.runId, path, status: STATUS[change.status] ?? 'modified', afterHash: diff.afterHash, adds: change.adds, dels: change.dels};
const empty = {palette: [], hunks: [], lines: [], from: 0, total: 0, next: null};
if (diff.binary || diff.image) return {...base, ...empty, binary: true} satisfies MobileMethods['changes.diff']['result'];
const lines = diff.truncated ? null : builtDiff(`${turn.runId}:${path}:${diff.beforeHash}:${diff.afterHash}`, () => buildDiff(path, diff.before, diff.after));
if (!lines) return {...base, ...empty, tooLarge: true} satisfies MobileMethods['changes.diff']['result'];
const page = await diffPage(lines, from);
return {...base, palette: page.palette, hunks: hunkInfo(lines, keptOf(marks, path)), lines: page.lines, from: Math.min(from, lines.lines.length), total: lines.lines.length, next: page.next} satisfies MobileMethods['changes.diff']['result'];
}
case 'changes.keep': {
const chat = await chatOf(id(p.chatId, 'chatId')), path = text(p.path, 'path', 4096);
const hunkIds = Array.isArray(p.hunkIds) && p.hunkIds.length && p.hunkIds.length <= 500 ? p.hunkIds.map(value => value === '*' ? '*' : id(value, 'hunk id')) : null;
if (!hunkIds) throw new MobileError('bad-request', 'Choose between 1 and 500 changes.');
const turn = await turnOf(ctx.invoke, chat, id(p.runId, 'runId'));
const changes = await ctx.invoke('review.changes', {folderId: turn!.folderId!, baseline: {runId: turn!.runId}});
if (!changes.files.some(file => file.path === path)) throw new MobileError('not-found', 'That file is not one of this turn’s changes.');
// Keeping the last hunk still to review keeps the whole file, as the desktop's file view does.
let all = hunkIds.includes('*');
if (!all) {
const diff = await ctx.invoke('review.fileDiff', {folderId: turn!.folderId!, path, baseline: {runId: turn!.runId}});
const lines = diff.binary || diff.truncated ? null : buildDiff(path, diff.before, diff.after);
const kept = keptOf(await ctx.invoke('review.marks', {runId: turn!.runId}), path);
all = Boolean(lines && lines.hunks.every(hunk => kept.has(hunk.id) || hunkIds.includes(hunk.id)));
}
await ctx.beforeReview?.();
recheck();
const marks = await ctx.invoke('review.keep', {runId: turn!.runId, path, hunkIds: all && !hunkIds.includes('*') ? [...hunkIds, '*'] : hunkIds});
const state: MobileReviewState = keptOf(marks, path).has('*') ? 'kept' : 'review';
return {state} satisfies MobileMethods['changes.keep']['result'];
}
case 'changes.undo': {
const chat = await chatOf(id(p.chatId, 'chatId')), path = text(p.path, 'path', 4096);
if (typeof p.afterHash !== 'string' || !HASH.test(p.afterHash)) throw new MobileError('bad-request', 'Read the file again and retry.');
const hunkId = p.hunkId === undefined ? undefined : id(p.hunkId, 'hunk id');
const turn = await turnOf(ctx.invoke, chat, id(p.runId, 'runId'));
const baseline = {runId: turn!.runId}, folderId = turn!.folderId!;
const changes = await ctx.invoke('review.changes', {folderId, baseline});
if (!changes.files.some(file => file.path === path)) throw new MobileError('not-found', 'That file is not one of this turn’s changes.');
await ctx.beforeReview?.();
recheck();
const result = hunkId
? await ctx.invoke('review.undoHunk', {folderId, path, baseline, hunkId, expectedAfterHash: p.afterHash})
: await ctx.invoke('review.undoFile', {folderId, path, baseline, expectedAfterHash: p.afterHash});
return {stale: result.stale} satisfies MobileMethods['changes.undo']['result'];
}
case 'chat.stop': {
const chatId = id(p.id); await chatOf(chatId);
recheck();
await ctx.invoke('chat.stop', {id: chatId});
return {} satisfies MobileMethods['chat.stop']['result'];
}
case 'needs.list': return {needs: await needsList(ctx.invoke, await ctx.snapshot(), ctx.scrub)} satisfies MobileMethods['needs.list']['result'];
}
} catch (error) {
Expand Down
Loading
Loading