Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
68e82fe
Trim the phone UI to its essentials
klNuno Oct 18, 2026
a050885
Phone: open an added project's draft, count CLI agents, update tests …
klNuno Oct 18, 2026
fe0fe87
Keep the space before a model chip's account
klNuno Oct 18, 2026
dcd4f0c
Merge remote-tracking branch 'origin/main' into boite/sub-gh0wnyeqct
klNuno Oct 18, 2026
58f4b23
ci: entry budget for the leaner phone app
klNuno Oct 18, 2026
fcf7919
Phone: keep the last answer's buttons while a prompt waits behind it
klNuno Oct 18, 2026
836a72b
Merge remote-tracking branch 'origin/main' into boite/sub-gh0wnyeqct
klNuno Oct 18, 2026
77daef3
test(e2e): phone menus without move and discussion settings
klNuno Oct 18, 2026
628a397
Keep the reader within half a pixel when a page lands; phone tour tes…
klNuno Oct 18, 2026
9c96aad
Merge remote-tracking branch 'origin/main' into boite/sub-gh0wnyeqct
klNuno Oct 18, 2026
622b449
Count message rows at the height they are drawn; usage e2e waits for …
klNuno Oct 18, 2026
2b72576
Merge remote-tracking branch 'origin/main' into boite/sub-gh0wnyeqct
klNuno Oct 18, 2026
9b4aedf
Task manager e2e: a paired phone opens it from a conversation's setti…
klNuno Oct 18, 2026
53e860a
Merge remote-tracking branch 'origin/main' into boite/sub-gh0wnyeqct
klNuno Oct 18, 2026
b45f15e
Merge remote-tracking branch 'origin/main' into boite/sub-gh0wnyeqct
klNuno Oct 19, 2026
36bea6a
Merge remote-tracking branch 'origin/main' into boite/sub-gh0wnyeqct
klNuno Oct 19, 2026
ab1f825
Merge remote-tracking branch 'origin/main' into boite/sub-gh0wnyeqct
klNuno Oct 19, 2026
0768ff6
Merge remote-tracking branch 'origin/main' into boite/sub-gh0wnyeqct
klNuno Oct 19, 2026
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
Binary file modified docs/images/phone-settings.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
62 changes: 42 additions & 20 deletions docs/phone.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,14 @@ remain on the machines.

## Settings on a phone

The top-right menu opens Conversations, Activity and Settings. There is no
bottom navigation bar. A conversation uses one compact header; Back returns
to the thread list, which includes a search field. The composer stays at the
The list's header holds the machine and project picker, whose last row adds a
project, the new-thread `+` and a menu with the machine, Conversations,
Activity, with its count, and Settings. There is no bottom navigation bar. A
conversation's header holds Back, which carries a mark while another thread
waits on the user, and its title, whose menu is the only one there. The thread
list opens on its search field, with the display options at its right end:
the view, by project or recent, and the grouping switches. The project order
and a project's own switches stay on the desktop. The composer stays at the
bottom of the available viewport, above the keyboard when it is open. A touch
keyboard has no Shift+Enter, so where the primary pointer is coarse
(`pointer: coarse`, the question the app's other touch rules ask) plain Enter
Expand All @@ -21,10 +26,15 @@ keyboard connected to such a device, so that keyboard's Enter writes a new
line too. A touchscreen laptop whose primary pointer is a mouse keeps Enter's
send.

At phone widths, Settings opens a vertical list with separate screens for
App & notifications, Appearance, and Machines. The back button or browser Back
returns to that list. Theme and accent belong to the current device; the
notification screen names the connected machine that will send its alerts.
At phone widths, Settings opens a list in two groups. This phone:
Notifications, Appearance and Privacy, the two analytics switches. Machine: the
machine itself with its connection, which opens Machines, then Usage, Voice and
Archived threads. Each opens a screen of its own; the back button or browser
Back returns to the list. Theme and accent belong to the current device;
Appearance also holds the chat's files and previews switch, and leaves the
column width, the terminal, the workspace and the header's buttons to the
desktop. The notification screen names the connected machine that will send
its alerts, and offers to install the app while it is not installed.
Machines shows the group: its machines, which the phone switches to or
reconnects, and its devices, this phone marked as such. A phone that reaches no
machine shows **Scan the code** and **Paste the link** there, as on its first
Expand All @@ -35,11 +45,15 @@ closing the view stops its tracks. A machine takes the hostname reported by its
core; its card can rename it.
Voice shows the connected core's dictation readiness. Engine installation and
API credentials stay in desktop Voice settings, including for owner sessions.
Usage shows the connected core's tokens and API cost per day, provider and
model; Limits draws the core's subscription bars, read-only ([usage.md](usage.md)).

Provider accounts, keyboard shortcuts, plugins, resource limits, adding and
removing devices and server configuration stay in desktop settings. This smaller
Usage has two tabs: Usage shows the connected core's tokens and API cost per
day, provider and model; Limits draws the core's subscription bars, read-only
([usage.md](usage.md)).

Provider accounts, keyboard shortcuts, plugins, resource limits, the brain, the
task manager, worktrees, automatic compaction, diagnostics, experiments, the
tour, adding and removing devices and server configuration stay in desktop
settings; a link or a card in a conversation that names one of those pages
still opens it. Project management stays on the desktop too. This smaller
phone menu also applies to owner sessions; it does not change RPC permissions.

Device sessions receive only events corresponding to the state they may read.
Expand Down Expand Up @@ -292,14 +306,22 @@ link scanned or pasted in the app asks nothing either: the owner just showed it.

The browser uses mobile navigation under 720 px. Conversations lists threads
across connected machines; Activity puts waiting requests first, followed by
running and queued turns. A phone has no right-click, so the actions a desktop
finds there open from a tap: the conversation's title in its header lists
rename, regenerate title, pin, copy path and archive, and the `...` button of a
row in the list offers pin, regenerate title and archive. The same title sheet
holds the Agents and Terminal toggles, so the header row keeps only the title,
the context ring and the Panel button: at 360 px in French, 192 px of a 193 px
title stay visible, where 96 px did with the toggles in the row. A draft's header shows its "New thread" label. Settings is the third destination. The header names
the machine, connection and project, and starts a new conversation.
running and queued turns; the header names the screen. A phone has no
right-click, so the actions a desktop finds there open from a tap: the
conversation's title in its header lists Browser, files and tools (the right
panel), pin, rename, find in this thread, Subagents when the thread has some,
archive and delete, and the `...` button of a row in the list offers the same
actions a thread keeps on a phone, with Mark done in the list. A new title by
the agent, moving to a project, the path, coordination and the terminal stay on
the desktop. A draft's header shows its "New thread" label. Settings is the
third destination.

Under a conversation, the copy, edit and retry buttons show under the last
prompt and the last answer; any other message shows its row when tapped, until
a second tap on it or one elsewhere (`lib/message-reveal.svelte.ts`). A phone
offers no fork. The turn's footer line, time, duration and tokens, stays. The
composer's model chip names the model alone, without its account, and drops
whole words when the row is short.

Model, effort and action menus open as bottom sheets. The browser's Back, the
Android Back gesture and a mouse's back button close the top sheet, dialog,
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -2096,6 +2096,9 @@ input[type=checkbox][role=switch]:disabled { opacity: .45; cursor: not-allowed;
.settings .page > .group-heading { margin-top: 28px; }
.settings .page .row { flex-wrap: wrap; }
.settings .page .switch-row { flex-wrap: wrap; gap: 12px; }
/* Buttons under the last prompt and answer, or the message tapped (lib/message-reveal.svelte.ts). A settled prompt's ticks go with them; a pending one keeps its own. */
.message[data-reveal]:not([data-latest]):not([data-revealed]) .message-actions,
.message[data-reveal]:not([data-latest]):not([data-revealed]) .receipts.settled { display: none; }
}

@media (prefers-reduced-motion: reduce) {
Expand Down
11 changes: 8 additions & 3 deletions packages/ui/src/app/phone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,18 @@ test('a paired device is offered none of the affordances the core refuses it', a
for (const id of OWNER_ONLY_COMMANDS) expect(commands).not.toContain(id);
store.paletteOpen = false;

// The phone's project menu: no Remove, and no transcript import behind it.
await showMobileThreads();
query<HTMLButtonElement>('[data-testid=mobile-project-actions]').click();
// A paired device's project menu: no Remove, and no transcript import behind it.
query('[data-testid=project-row]').dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }));
await waitFor(() => document.querySelector('[data-testid=context-menu]') !== null);
expect(menuValues()).toEqual(['new', 'copy', 'archived', 'manage']);
query<HTMLButtonElement>('[data-testid=context-menu] [data-value=manage]').click();
await waitFor(() => document.querySelector('[data-testid=context-menu] [data-value=archive-project]') !== null);
expect(menuValues()).toEqual(['back', 'archive-project']);
press('Escape');
await waitFor(() => document.querySelector('[data-testid=context-menu]') === null);
// The phone's own header manages no project at all.
await showMobileThreads();
expect(document.querySelector('[data-testid=mobile-project-actions]')).toBeNull();

store.showSettings('machines');
await waitFor(() => document.querySelector('[data-testid=phone-settings]') !== null);
Expand Down Expand Up @@ -148,6 +150,9 @@ test('the desktop still has every one of them', async ({ app: _app }) => {
expect(menuValues()).toEqual(['new', 'copy', 'archived', 'manage']);
press('Escape');
await waitFor(() => document.querySelector('[data-testid=context-menu]') === null);
// The phone's own header manages no project at all.
await showMobileThreads();
expect(document.querySelector('[data-testid=mobile-project-actions]')).toBeNull();

store.showSettings('machines');
await waitFor(() => document.querySelector('[data-testid=group-add-device]') !== null);
Expand Down
36 changes: 18 additions & 18 deletions packages/ui/src/app/projects-add.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { flushSync } from 'svelte';
import { type RpcMethodName } from '@boite/contracts';
import { store } from '../lib/store.svelte';
import { work } from '../lib/work-prefs.svelte';
import { projectMenu } from '../lib/project-menu';
import { query, showMobileThreads, test, type, waitFor } from '../test/app';

vi.mock('@tauri-apps/plugin-opener', () => import('../test/opener'));
Expand Down Expand Up @@ -49,30 +50,29 @@ test('browser Add project keeps refused paths and Escape returns to its button',
}
});

test('the phone cannot manage an unrelated project from a draft without a folder', async ({ app: _app }) => {
expect(store.projects.length).toBeGreaterThan(0);
store.startDraft(null);
await showMobileThreads();
await waitFor(() => query('[data-testid=mobile-project]').textContent?.includes('Drafts') === true);
expect(store.openProject).toBeNull();
expect(document.querySelector('[data-testid=mobile-project-actions]')).toBeNull();
test('the phone list header holds the project picker, the new thread and the menu, and no project management', async ({ app: _app }) => {
store.startDraft('p-boite');
await waitFor(() => document.querySelector('[data-testid=mobile-project-actions]') !== null);
await store.open('t-trace');
if (!store.openThread) throw new Error('the thread did not open');
store.openThread.projectId = null;
flushSync();
expect(store.openProject).toBeNull();
await showMobileThreads();
await waitFor(() => query('[data-testid=mobile-project]').textContent?.includes('boite') === true);
expect(document.querySelector('[data-testid=mobile-new]')).not.toBeNull();
expect(document.querySelector('[data-testid=mobile-menu]')).not.toBeNull();
expect(document.querySelector('[data-testid=mobile-project-actions]')).toBeNull();
// Adding a project is the picker's last row.
query<HTMLButtonElement>('[data-testid=mobile-project]').click();
await waitFor(() => document.querySelector('[data-value=add-project]') !== null);
query<HTMLButtonElement>('[data-value=add-project]').click();
await waitFor(() => store.projectPickerOpen);
});

test('the phone cannot archive an already archived project or offer a misleading undo', async ({ app: _app }) => {
await store.open('t-trace');
await showMobileThreads();
const project = store.openProject!;
test('a project menu cannot archive an already archived project or offer a misleading undo', async ({ app: _app }) => {
const row = query('[data-testid=project-row]');
const project = store.projects.find(p => p.id === row.getAttribute('data-project-id'))!;
const archive = vi.spyOn(store, 'archiveProject');
// The menu opens on the row; once archived the row leaves the list, and the menu is asked for the archived project itself.
const openManagement = async () => {
query<HTMLButtonElement>('[data-testid=mobile-project-actions]').click();
const current = store.projects.find(p => p.id === project.id)!;
if (current.archived) projectMenu(new MouseEvent('contextmenu', { clientX: 10, clientY: 10 }), store, current);
else query(`[data-testid=project-row][data-project-id="${project.id}"]`).dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }));
await waitFor(() => document.querySelector('[data-value=manage]') !== null);
query<HTMLButtonElement>('[data-value=manage]').click();
await waitFor(() => document.querySelector('[data-value=archive-project]') !== null);
Expand Down
20 changes: 19 additions & 1 deletion packages/ui/src/components/AppearancePage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,15 @@
import type { Store } from '../lib/store.svelte';
import SurfaceIcon from './SurfaceIcon.svelte';
import { chatPrefs, setChatPref } from '../lib/chat-prefs.svelte';
import { featureOn } from '../lib/features.svelte';
import { setFeature } from '../lib/features';

let { store }: { store: Store } = $props();
/**
* `phone`: what a phone reads and looks like. The column width, the terminal,
* the workspace and the header's buttons are the desktop's; the chat's files
* and previews switch, on the desktop's General page, joins the display here.
*/
let { store, phone = false }: { store: Store; phone?: boolean } = $props();
const uid = $props.id();

// The owner's buttons (the terminal, Add a project) are not a paired device's to hide.
Expand Down Expand Up @@ -209,6 +216,7 @@
</div>
</div>
<ThemeColors />
{#if !phone}
<div class="switch-row">
<span class="text ui-label">{strings.settings.chatWidth}</span>
<div class="segmented" role="group" aria-label={strings.settings.chatWidth}>
Expand All @@ -230,6 +238,7 @@
<input type="checkbox" role="switch" checked={terminalGpu} data-testid="terminal-gpu"
onchange={(event) => { terminalGpu = event.currentTarget.checked; setTerminalGpu(terminalGpu); }} />
</label>
{/if}
<label class="switch-row">
<span class="text ui-label">{strings.settings.groupChanges}</span>
<input type="checkbox" role="switch" checked={chatPrefs.groupChanges} data-testid="chat-group-changes" onchange={event => setChatPref('groupChanges', event.currentTarget.checked)} />
Expand All @@ -238,6 +247,13 @@
<span class="text ui-label">{strings.settings.expandDiffs}</span>
<input type="checkbox" role="switch" checked={chatPrefs.expandDiffs} data-testid="chat-expand-diffs" onchange={event => setChatPref('expandDiffs', event.currentTarget.checked)} />
</label>
{#if phone}
<label class="switch-row" for="{uid}-chat-artifacts">
<span class="text ui-label-box"><span class="ui-label" id="{uid}-chat-artifacts-name">{strings.features.chatArtifacts.title}</span><InfoTip topic={strings.features.chatArtifacts.title} text={strings.features.chatArtifacts.hint} /></span>
<input id="{uid}-chat-artifacts" aria-labelledby="{uid}-chat-artifacts-name" type="checkbox" role="switch" checked={featureOn('chat-artifacts')} data-testid="setting-chat-artifacts"
onchange={event => setFeature('chat-artifacts', event.currentTarget.checked)} />
</label>
{/if}
{#if hasMaterial}
<div class="switch-row">
<span class="text ui-label">
Expand Down Expand Up @@ -294,6 +310,7 @@
{/if}
</section>

{#if !phone}
<!-- What the tour's question set, each piece on its own. -->
<section class="card" id="settings-workspace">
<h2>{strings.settings.workspace}</h2>
Expand Down Expand Up @@ -349,6 +366,7 @@
</div>
{/each}
</section>
{/if}
</div>

<style>
Expand Down
5 changes: 4 additions & 1 deletion packages/ui/src/components/ComposerBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,10 @@
.chips { flex: 1; flex-wrap: nowrap; }
.chips :global(.picker) { min-width: 0; max-width: 100%; }
.chips :global(.picker > .trigger) { max-width: 100%; height: var(--touch-target); padding: 0 6px; border: none; background: transparent; font-weight: 500; color: var(--color-muted-foreground); }
.chips :global(.picker > .trigger > .label) { min-width: 0; max-width: none; }
/* The model's name alone, and whole words of it: one line high, a name too long for the row
wraps its last words onto a second line nobody sees, never half of one. */
.chips :global(.picker > .trigger > .label) { min-width: 0; max-width: none; height: 1lh; padding-block: 0; margin-block: 0; text-box: normal; overflow: hidden; white-space: normal; overflow-wrap: normal; }
.chips :global(.picker > .trigger .account-name) { display: none; }
.chips :global(.picker > .trigger.connect) { padding: 0 12px; border-radius: var(--radius-md); background: var(--color-accent-soft); color: var(--color-accent); }
.send, .stop { margin-left: 2px; }
}
Expand Down
26 changes: 8 additions & 18 deletions packages/ui/src/components/ConversationSwitches.svelte
Original file line number Diff line number Diff line change
@@ -1,29 +1,26 @@
<script lang="ts">
import { FileText, ListFilter } from '@lucide/svelte';
import { featureOn } from '../lib/features.svelte';
import { setFeature } from '../lib/features';
import { recentPreferences } from '../lib/recent.svelte';
import { strings } from '../lib/strings';
import InfoTip from './InfoTip.svelte';

/**
* The conversation switches kept on this device, the same rows in the
* desktop's General page and the phone's settings: how the thread lists
* group, and whether answers show their files and previews.
* The conversation switches kept on this device, rows of the desktop's
* General page: how the thread lists group, and whether answers show their
* files and previews. A phone groups from the list's display options and
* finds the files switch under Appearance.
*/
let { mobile = false }: { mobile?: boolean } = $props();
const uid = $props.id();
const options = $derived([
{ id: 'group-working-threads', icon: ListFilter, label: strings.settings.groupWorkingThreads, hint: strings.settings.groupWorkingThreadsHint, checked: recentPreferences.groupWorking, set: (value: boolean) => recentPreferences.setGroupWorking(value) },
{ id: 'group-other-projects', icon: ListFilter, label: strings.settings.groupOtherProjects, hint: strings.settings.groupOtherProjectsHint, checked: recentPreferences.groupOtherProjects, set: (value: boolean) => recentPreferences.setGroupOtherProjects(value) },
{ id: 'chat-artifacts', icon: FileText, label: strings.features.chatArtifacts.title, hint: strings.features.chatArtifacts.hint, checked: featureOn('chat-artifacts'), set: (value: boolean) => setFeature('chat-artifacts', value) }
{ id: 'group-working-threads', label: strings.settings.groupWorkingThreads, hint: strings.settings.groupWorkingThreadsHint, checked: recentPreferences.groupWorking, set: (value: boolean) => recentPreferences.setGroupWorking(value) },
{ id: 'group-other-projects', label: strings.settings.groupOtherProjects, hint: strings.settings.groupOtherProjectsHint, checked: recentPreferences.groupOtherProjects, set: (value: boolean) => recentPreferences.setGroupOtherProjects(value) },
{ id: 'chat-artifacts', label: strings.features.chatArtifacts.title, hint: strings.features.chatArtifacts.hint, checked: featureOn('chat-artifacts'), set: (value: boolean) => setFeature('chat-artifacts', value) }
]);
</script>

{#each options as option (option.id)}
<!-- On a desktop the rows are the settings page's own switch rows, the same face as the card's others. -->
<label for="{uid}-{option.id}" class={mobile ? 'grouping-row mobile' : 'switch-row'}>
{#if mobile}<option.icon size={20} />{/if}
<label for="{uid}-{option.id}" class="switch-row">
<span class="text ui-label-box">
<span class="ui-label" id="{uid}-{option.id}-name">{option.label}</span><InfoTip topic={option.label} text={option.hint} />
</span>
Expand All @@ -33,12 +30,5 @@
{/each}

<style>
.grouping-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.grouping-row .text { flex: 1; min-width: 0; font-size: var(--text-sm); }
/* The phone's settings rows (MobileSettings.svelte): the same height, padding and type as the links beside them. */
.mobile { min-height: 56px; padding: 12px 16px; border-bottom: 1px solid var(--color-border); }
.mobile .text { font-size: var(--text-base); font-weight: 500; color: var(--color-foreground); }
.mobile:last-child { border-bottom: 0; }
.mobile :global(svg) { flex: none; color: var(--color-muted-foreground); }
input { flex: none; }
</style>
Loading
Loading