diff --git a/docs/images/phone-settings.png b/docs/images/phone-settings.png index 103e27cc2..d1d31c3b0 100644 Binary files a/docs/images/phone-settings.png and b/docs/images/phone-settings.png differ diff --git a/docs/phone.md b/docs/phone.md index 9654fa977..38d5f1b6b 100644 --- a/docs/phone.md +++ b/docs/phone.md @@ -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 @@ -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 @@ -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. @@ -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, diff --git a/packages/ui/src/app.css b/packages/ui/src/app.css index 1a0a8ea52..d25aa3c66 100644 --- a/packages/ui/src/app.css +++ b/packages/ui/src/app.css @@ -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) { diff --git a/packages/ui/src/app/phone.test.ts b/packages/ui/src/app/phone.test.ts index b8c3fb787..7e26f743f 100644 --- a/packages/ui/src/app/phone.test.ts +++ b/packages/ui/src/app/phone.test.ts @@ -65,9 +65,8 @@ 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('[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('[data-testid=context-menu] [data-value=manage]').click(); @@ -75,6 +74,9 @@ test('a paired device is offered none of the affordances the core refuses it', a 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); @@ -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); diff --git a/packages/ui/src/app/projects-add.test.ts b/packages/ui/src/app/projects-add.test.ts index c0d535b98..ef2fbb49b 100644 --- a/packages/ui/src/app/projects-add.test.ts +++ b/packages/ui/src/app/projects-add.test.ts @@ -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')); @@ -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('[data-testid=mobile-project]').click(); + await waitFor(() => document.querySelector('[data-value=add-project]') !== null); + query('[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('[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('[data-value=manage]').click(); await waitFor(() => document.querySelector('[data-value=archive-project]') !== null); diff --git a/packages/ui/src/components/AppearancePage.svelte b/packages/ui/src/components/AppearancePage.svelte index 0e13794e7..c73d78638 100644 --- a/packages/ui/src/components/AppearancePage.svelte +++ b/packages/ui/src/components/AppearancePage.svelte @@ -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. @@ -209,6 +216,7 @@ + {#if !phone}
{strings.settings.chatWidth}
@@ -230,6 +238,7 @@ { terminalGpu = event.currentTarget.checked; setTerminalGpu(terminalGpu); }} /> + {/if} + {#if phone} + + {/if} {#if hasMaterial}
@@ -294,6 +310,7 @@ {/if} + {#if !phone}

{strings.settings.workspace}

@@ -349,6 +366,7 @@
{/each} + {/if}
diff --git a/packages/ui/src/components/DisplayOptions.svelte b/packages/ui/src/components/DisplayOptions.svelte index 8f128679f..18dc2b486 100644 --- a/packages/ui/src/components/DisplayOptions.svelte +++ b/packages/ui/src/components/DisplayOptions.svelte @@ -13,7 +13,8 @@ * press still opens it at once. It stays open while the user adjusts it; a * press outside or Escape puts it away. */ - let { entries, target, owner, prefix = '' }: { entries: ProjectEntry[]; target: Project | undefined; owner: Store; prefix?: string } = $props(); + /** `compact`: the phone's choices only, the view and the groups; the order and a project's own switches stay on the desktop. */ + let { entries, target, owner, prefix = '', compact = false }: { entries: ProjectEntry[]; target: Project | undefined; owner: Store; prefix?: string; compact?: boolean } = $props(); const popover = new Closing(); let root = $state(); @@ -54,7 +55,7 @@ {#if popover.shown && Panel} - trigger ?? null} onclose={close} {entries} {target} {owner} {prefix} /> + trigger ?? null} onclose={close} {entries} {target} {owner} {prefix} {compact} /> {/if}
diff --git a/packages/ui/src/components/DisplayOptionsPanel.svelte b/packages/ui/src/components/DisplayOptionsPanel.svelte index 6ea34a4e3..b4aa5f16f 100644 --- a/packages/ui/src/components/DisplayOptionsPanel.svelte +++ b/packages/ui/src/components/DisplayOptionsPanel.svelte @@ -21,7 +21,7 @@ * switch sit behind its "i". `target` is the project the last section speaks * for, owned by `owner`, which may be another machine's Store. */ - let { popover, anchor, onclose, entries, target, owner, prefix = '' }: { + let { popover, anchor, onclose, entries, target, owner, prefix = '', compact = false }: { popover: Closing; anchor: () => HTMLElement | null; onclose: (refocus?: boolean) => void; @@ -29,6 +29,8 @@ target: Project | undefined; owner: Store; prefix?: string; + /** A phone's panel: the view and the groups only. */ + compact?: boolean; } = $props(); let panel = $state(); @@ -36,7 +38,7 @@ const projects = $derived(workspace.view === 'projects'); /** The merged-PR switch is the project's, on cores that know it, for a repository the owner controls. */ - const merged = $derived(target && owner.owner && target.autoArchiveMergedPr !== undefined && target.repository !== false && target.kind !== 'drafts' ? target : null); + const merged = $derived(!compact && target && owner.owner && target.autoArchiveMergedPr !== undefined && target.repository !== false && target.kind !== 'drafts' ? target : null); // Both follow the project on screen: the section's heading names the project a press writes to, and a lost connection disables it. const mergedBusy = $derived(merged ? owner.connection !== 'ready' || owner.projectAutoArchiveMergedPrBusy(merged.id) : true); /** The worktree removal switch, beside it on cores that know it. */ @@ -105,7 +107,7 @@ {@render segment('view:recent', strings.sidebar.recentView, !projects, List, () => workspace.setView('recent'))} - {#if projects && entries.length} + {#if projects && entries.length && !compact}

{strings.sidebar.displayOrder}

diff --git a/packages/ui/src/components/MessageActions.svelte b/packages/ui/src/components/MessageActions.svelte index 78be16f20..46f5a3eed 100644 --- a/packages/ui/src/components/MessageActions.svelte +++ b/packages/ui/src/components/MessageActions.svelte @@ -125,7 +125,7 @@ } /* A finger takes the full target, as every other phone control does. */ - @media (pointer: coarse) { + @media (pointer: coarse), (max-width: 720px) { .message-actions { gap: 0; } .act { width: var(--touch-target); height: var(--touch-target); } } diff --git a/packages/ui/src/components/MessageList.svelte b/packages/ui/src/components/MessageList.svelte index bd0334c94..6bea506c3 100644 --- a/packages/ui/src/components/MessageList.svelte +++ b/packages/ui/src/components/MessageList.svelte @@ -39,11 +39,13 @@ import { editWhole, fillShort, pullNewer, pullOlder } from '../lib/reading-window'; import { ReadingAnchor, type RowAnchor } from '../lib/reading-anchor'; import { MediaQuery } from 'svelte/reactivity'; + import { MessageReveal } from '../lib/message-reveal.svelte'; import { placeViews, turnViews, viewPart } from '../lib/inline-view'; import { filesLast, publishedFile, quietKeepalives } from '../lib/message-order'; /** A phone has no room left of the bubbles for the outline rail: it is not drawn there. */ const narrow = new MediaQuery('(max-width: 720px)'); + const reveal = new MessageReveal(() => messages, () => narrow.current); let { store, @@ -394,11 +396,11 @@ } const id = node.dataset['mid']; if (!id) continue; - // The observer already measured the box; reading `offsetHeight` again would lay out, mid-scroll, - // whatever the window just mounted. A row that continues a message sits a paragraph's gap under it. + // The box is measured already: `offsetHeight` would lay out, mid-scroll, what the window just mounted. Fractions + // stay (rounded, each row above the reader left it up to half a pixel off); a continued message sits a gap under. const gap = slotGap(node.dataset['seam'], node.dataset['rest'] !== undefined); node.dataset['gap'] = String(gap); - const next = Math.round(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight) + gap; + const next = (entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight) + gap; const at = slots.indexOf(rows, id); const previous = heights.get(id) ?? slots.estimateOf(rows[at]); if (previous === next) continue; @@ -752,9 +754,9 @@ {/if} {#if !narrow.current} void jumpToMessage(id)} hasOlder={store.messagesBefore !== null} loading={store.loadingOlder} loadOlder={() => { if (viewport) { releaseNavigation(); viewport.scrollTop = rows[0]?.gap ? Math.max(0, (totals(rows)[1] ?? 0) - viewport.clientHeight) : 0; pinned = false; loadAbove(viewport); } }} />{/if} - - -
{ pointed = pointedTurn(event); }} onpointerleave={() => { pointed = null; }} onkeydown={releaseNavigation} style:padding-top="{20 + promptLead}px" style:overflow-anchor={timeline.at(-1)?.state === 'streaming' ? 'none' : undefined} data-testid="timeline" data-media-gallery data-scroll-root> + + +
{ pointed = pointedTurn(event); }} onpointerleave={() => { pointed = null; }} onkeydown={releaseNavigation} style:padding-top="{20 + promptLead}px" style:overflow-anchor={timeline.at(-1)?.state === 'streaming' ? 'none' : undefined} data-testid="timeline" data-media-gallery data-scroll-root>
{#if store.loadingOlder}

{strings.chat.loadingOlder}

{/if} @@ -772,7 +774,7 @@
Promise.all(files.deferred.map(source => store.loadToolOutput(threadId, source.messageId, source.toolId))).then(() => {})} />{/if} - {/if} - {#if turn && closes} {#snippet actions()} turnAnswer(messages, turn.id)} retry={atRest && store.openThread?.turns.at(-1)?.id === turn.id ? () => void retry(turn.id) : undefined} - fork={branchable && !store.openThread?.incognito && source && source.state !== 'streaming' ? (worktree) => void store.fork(source.id, { worktree }) : undefined} + fork={!narrow.current && branchable && !store.openThread?.incognito && source && source.state !== 'streaming' ? (worktree) => void store.fork(source.id, { worktree }) : undefined} /> {/snippet} diff --git a/packages/ui/src/components/MessageList.test.ts b/packages/ui/src/components/MessageList.test.ts index 4cf6ff03c..6908e8adb 100644 --- a/packages/ui/src/components/MessageList.test.ts +++ b/packages/ui/src/components/MessageList.test.ts @@ -1341,3 +1341,84 @@ test('a place read inside a long message is saved by message with its row, and o // The store asks the core for the page around a message: the anchor names it, never a row. expect(live.readingPositions.get('t-heavy')?.anchor).toEqual({ id: giant.id, offset: -30, row: first }); }); + +test('a phone keeps the buttons under the last prompt and answer, shows another prompt\'s on a tap and offers no fork', async ({ ready }) => { + window.localStorage.clear(); + vi.stubGlobal('matchMedia', (media: string) => Object.assign(new EventTarget(), { media, matches: media.includes('max-width: 720px'), onchange: null, addListener() {}, removeListener() {} })); + onTestFinished(() => { vi.unstubAllGlobals(); }); + const { store: live } = await ready({ delayMs: 0, long: true }); + await live.open('t-long'); + const messages = live.openThread?.messages ?? []; + stubLayout(messages.length * ESTIMATE); + running = mount(MessageList, { target: document.body, props: { store: live, threadId: 't-long', messages } }); + await settle(); + + const prompts = () => [...document.querySelectorAll('article.message.user')]; + const last = prompts().at(-1)!, earlier = prompts().at(-2)!; + expect(last.hasAttribute('data-latest')).toBe(true); + expect(earlier.hasAttribute('data-latest')).toBe(false); + expect(document.querySelector('article.message.assistant[data-latest] [data-testid=message-actions]')).not.toBeNull(); + expect(document.querySelector('[data-testid=message-fork]')).toBeNull(); + + const bubble = () => earlier.querySelector('.bubble')!; + bubble().click(); flushSync(); + expect(earlier.hasAttribute('data-revealed')).toBe(true); + // Its own buttons keep the row; a second tap on the bubble, or one anywhere else, puts it away. + earlier.querySelector('[data-testid=message-copy]')!.click(); flushSync(); + expect(earlier.hasAttribute('data-revealed')).toBe(true); + bubble().click(); flushSync(); + expect(earlier.hasAttribute('data-revealed')).toBe(false); + bubble().click(); flushSync(); + document.body.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); flushSync(); + expect(earlier.hasAttribute('data-revealed')).toBe(false); +}); + +test('a prompt queued behind a finished turn keeps the buttons of the answer before it on a phone', async ({ ready }) => { + window.localStorage.clear(); + vi.stubGlobal('matchMedia', (media: string) => Object.assign(new EventTarget(), { media, matches: media.includes('max-width: 720px'), onchange: null, addListener() {}, removeListener() {} })); + onTestFinished(() => { vi.unstubAllGlobals(); }); + const { store: live } = await ready({ delayMs: 0, long: true }); + await live.open('t-long'); + const thread = live.openThread!; + const messages = thread.messages; + const answer = messages.findLast((message) => message.role === 'assistant')!; + // Its turn is queued and nothing answers it yet: the newest turn has no answer row. + thread.turns.push({ id: 'turn-queued', threadId: 't-long', status: 'queued', queuedAt: Date.now(), startedAt: null, finishedAt: null, usage: null, error: null }); + messages.push({ id: 'm-queued', threadId: 't-long', turnId: 'turn-queued', role: 'user', parts: [{ type: 'text', text: 'and then?' }], state: 'complete', createdAt: Date.now() }); + stubLayout(messages.length * ESTIMATE); + running = mount(MessageList, { target: document.body, props: { store: live, threadId: 't-long', messages } }); + await settle(); + + expect(document.querySelector('[data-mid="m-queued"]')?.closest('article')?.hasAttribute('data-latest')).toBe(true); + expect(document.querySelector(`[data-mid="${answer.id}"]`)?.closest('article')?.hasAttribute('data-latest')).toBe(true); +}); + +test('a row counts at the height it is drawn, fractions included, so rows measured above the reader move the view by what they weigh', async () => { + const messages = thread(200); + stubLayout(messages.length * ESTIMATE); + const observers: Array<{ callback: ResizeObserverCallback }> = []; + vi.stubGlobal('ResizeObserver', class { + callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { this.callback = callback; observers.push(this); } + observe() {} + unobserve() {} + disconnect() {} + }); + onTestFinished(() => { vi.unstubAllGlobals(); }); + running = mount(MessageList, { target: document.body, props: { store, threadId: 't-long', messages } }); + await settle(); + const timeline = document.querySelector('[data-testid=timeline]')!; + timeline.scrollTop = 4_000; + timeline.dispatchEvent(new Event('scroll')); + await settle(); + const before = timeline.scrollTop; + // Each drawn row is 0.37 px taller than a whole pixel, as text lays out on some screens and font metrics. + const observer = observers.at(-1)!; + observer.callback(articles().map((target) => ({ target, borderBoxSize: [{ blockSize: 100.37, inlineSize: 300 }] }) as unknown as ResizeObserverEntry), observer as unknown as ResizeObserver); + await new Promise((resolve) => requestAnimationFrame(() => resolve(null))); + await settle(); + const moved = timeline.scrollTop - before; + expect(moved).toBeGreaterThan(0); + // Rounded heights would move it by whole pixels and leave the reader's text a fraction off per row. + expect(Math.abs(moved - Math.round(moved))).toBeGreaterThan(0.01); +}); diff --git a/packages/ui/src/components/MobileMenu.svelte b/packages/ui/src/components/MobileMenu.svelte index 1674437ee..abb72654d 100644 --- a/packages/ui/src/components/MobileMenu.svelte +++ b/packages/ui/src/components/MobileMenu.svelte @@ -1,14 +1,13 @@
{#if chatting} - + + {:else if recover} {:else} @@ -175,17 +177,15 @@ {store.draftInDrafts && !store.openThread ? strings.drafts.name : project ? projectName(project) : strings.mobile.project} {:else} - {store.page === 'settings' ? strings.settings.heading : store.page === 'agents' ? strings.agents.heading : 'Boite'} + {store.page === 'settings' ? strings.settings.heading : store.page === 'agents' ? strings.agents.heading : screen === 'activity' ? strings.mobile.activity : 'Boite'} {/if}
{/if} - {#if actionProject && screen === 'threads' && !recover && store.page === 'chat'} - - {/if} - {#if !recover && !chatting}{/if} - + + {#if !recover && !chatting && store.page !== 'agents'}{/if} + + {#if !chatting}{/if} {#if narrow.current && store.page === 'chat' && (screen !== 'chat' || recover)} @@ -193,12 +193,22 @@ {#if recover && narrow.current} show('threads')} /> {:else} +
-

{screen === 'activity' ? strings.mobile.activity : strings.mobile.threads}

{#if screen === 'activity'}{/if}
- {#if screen === 'threads'}{/if} - - {#if atWorkRows.length > 0} - + + {#if (inRecent && groups.length) || atWorkRows.length > 0} +
+ {#if inRecent && groups.length} + projectView.pick(key)} label={strings.sidebar.filterProject} placement="bottom" variant="ghost" testid="mobile-project-filter"> + {#if selected}{:else}{/if}{selected ? projectName(selected.project) : strings.sidebar.allProjects}{#if selected && several}{selected.machine.label}{/if} + + {/if} + {#if atWorkRows.length > 0} + + {/if} +
{/if}
{#snippet threadRow(row: typeof rows[number])} @@ -306,13 +316,26 @@ .chat-heading :global(.thread-header) { gap: 2px; } .chat-heading :global(.launcher span) { display: none; } .chat-heading :global(.launcher) { width: 44px; padding: 0; justify-content: center; flex: none; } - .search { display: flex; gap: 8px; align-items: center; padding: 0 10px; margin-top: 12px; background: var(--color-surface-2); border: 1px solid var(--color-border); border-radius: var(--radius-lg); color: var(--color-muted-foreground); } + .search { display: flex; gap: 8px; align-items: center; padding: 0 0 0 12px; background: var(--color-surface-2); border: 1px solid var(--color-border); border-radius: var(--radius-lg); color: var(--color-muted-foreground); } + .search > :global(svg) { flex: none; } + /* The display options close the field's row, after a hairline. */ + .display { display: flex; align-self: stretch; align-items: center; flex: none; border-left: 1px solid var(--color-border); } + .display :global(.trigger) { width: var(--touch-target); height: var(--touch-target); border-radius: 0 var(--radius-lg) var(--radius-lg) 0; } .search input { flex: 1; min-width: 0; border: 0; background: transparent; min-height: 44px; font-size: 16px; padding: 8px 0; } .search:focus-within { border-color: var(--color-accent); } .search input:focus { outline: none; } - .at-work { display: inline-flex; gap: 6px; align-items: center; height: auto; min-height: var(--touch-target); margin-top: 10px; padding: 0 12px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface-2); color: var(--color-accent); font-size: var(--text-sm); font-weight: 500; } + .filters { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } + .filters :global(.menu) { min-width: 0; max-width: 100%; } + .at-work, .filters :global(.trigger) { display: inline-flex; gap: 6px; align-items: center; height: auto; min-height: var(--touch-target); max-width: 100%; padding: 0 12px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface-2); font-size: var(--text-sm); font-weight: 500; } + .at-work { color: var(--color-accent); } + .filters :global(.trigger) { color: var(--color-foreground); } + .filters :global(.trigger svg) { flex: none; color: var(--color-muted-foreground); } + .filters :global(.trigger .ui-label) { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .filters .filter-machine { color: var(--color-muted-foreground); font-weight: 400; } .at-work.on { border-color: var(--color-accent); background: var(--color-surface); } .brand-icon { flex: none; border-radius: var(--radius-md); } + .back { position: relative; flex: none; } + .waiting-mark { position: absolute; top: 9px; right: 8px; width: 8px; height: 8px; border-radius: var(--radius-full); background: var(--color-live); box-shadow: 0 0 0 2px var(--color-background); } .home { flex: none; padding: 0; } .brand strong { display: block; font-size: var(--text-md); font-weight: 600; letter-spacing: -0.3px; } .connection { display: flex; gap: 5px; height: auto; min-height: var(--touch-target); max-width: 100%; padding: 0; font-size: var(--text-xs); color: var(--color-muted-foreground); } @@ -326,13 +349,7 @@ /* A finger-sized target without growing the header: the padding reaches over the machine line and the header's own padding. */ .identity :global(.trigger) { min-height: var(--touch-target); margin-block: -13px -6px; } .mobile-list { display: block; position: absolute; inset: 0; overflow-y: auto; overscroll-behavior: contain; background: var(--color-background); padding: 8px max(16px, env(safe-area-inset-right)) max(20px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); } - .list-heading { padding: 18px 0 12px; } - .list-heading :global(.rail-head) { padding-top: 16px; } - .list-heading :global(.toolbar) { background: var(--color-surface-2); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 3px; gap: 4px; } - .list-heading :global(.view) { font-size: var(--text-sm); } - .list-heading :global(.chosen) { background: var(--color-surface); box-shadow: inset 0 0 0 1px var(--color-edge); border-radius: var(--radius-md); } - .heading-row { display: flex; align-items: center; justify-content: space-between; } - h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.5px; margin: 0; } + .list-heading { padding: 8px 0 14px; } p { font-size: var(--text-sm); } .project-group { border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-surface); margin-bottom: 12px; overflow: hidden; } .project-heading { display: flex; align-items: center; padding: 8px 12px; gap: 8px; background: var(--color-surface-2); color: var(--color-muted-foreground); border-bottom: 1px solid var(--color-border); } diff --git a/packages/ui/src/components/MobileSettings.svelte b/packages/ui/src/components/MobileSettings.svelte index fed36bb99..e08637920 100644 --- a/packages/ui/src/components/MobileSettings.svelte +++ b/packages/ui/src/components/MobileSettings.svelte @@ -1,12 +1,10 @@