ADR 0003 — The desktop interaction model
Status: accepted · Date: 2026-08-21 · Supersedes nothing. Continues ADR 0001 §8, which shipped a working desktop shell and named what it deliberately left out.
Context
Section titled “Context”Phase 5 gave the desktop a NavigationRail, a one-way back stack and Ctrl/Cmd+N, and called
it what it was: “a working shell, not a finished one”. Everything else was the Android app in
a 1100×780 window. A phone app run on a desktop is not wrong so much as unfinished: the pointer
can only tap, the keyboard can only type, and a window three times the size of a phone screen
still shows one thing at a time.
This ADR records the decisions behind closing that gap — the epic tracked as #123 — because most of them are the kind that look arbitrary in six months.
Decisions
Section titled “Decisions”1. Manual order is dense integers, renumbered from 0
Section titled “1. Manual order is dense integers, renumbered from 0”Task, Project and Section have all carried a sortOrder since the first schema,
SortMode.MANUAL has always sorted by it, and the Postgres mirror has always synced it — and
nothing ever wrote one. Every row was inserted at 0, so “manual” order was id order.
CadenceRepository.reorderTasks/reorderProjects/reorderSections renumber a whole list densely.
Not a gap scheme (100, 200, 300…), not fractional midpoints:
- Sync merges rows, not lists. Two devices reordering the same list while offline interleave under last-writer-wins whatever the numbering is. The clever schemes buy no conflict resistance here — they would in a CRDT, and this is not one (ADR 0002 chose last-writer-wins outright).
- What they do buy is fewer rows written per drag, and the price is a renormalisation pass nobody can trigger deterministically plus numbers that stop being readable in the database.
- The
sortOrder != :sortOrderguard in the UPDATE recovers most of that saving anyway: moving one row in a ten-row list writes the rows that actually moved, so the push carries those and nothing else.
2. The drag kernel is ours, not the platform’s
Section titled “2. The drag kernel is ours, not the platform’s”Compose Multiplatform has Modifier.dragAndDropSource/Target. It is the wrong tool here:
- Its payload is a
ClipEntrywrapping an AWTTransferableon the desktop and aClipDataon Android. Building one needs anexpect/actualseam, and:uihas none —jvmSharedis a single source set feeding both targets, on purpose (ADR 0001, decision 3). - Every drag in this app is in-process and carries a domain object. Serialising a
Taskto a MIME type so the same process can parse it back is work in exchange for nothing. - It has no concept of an insertion caret between two rows, which is most of what these drags have to show.
- None of it is reachable from a JVM unit test, and the interesting half of a drag is the rules.
So :ui/dnd/ is two files. DragModel.kt is pure: payloads, targets, intents, resolveDrop,
hitTest. DragAndDropKt is the gesture, the ghost, the caret and the hover highlight, and
decides nothing. The split is what makes “a project cannot nest under its own child” a test rather
than a thing to remember.
A drop that would change nothing is Rejected, not a no-op write. The hover highlight is
driven by the same function that runs the drop, so a row that lights up is a promise that
something will happen.
hitTest picks the innermost zone by area and skips one that rejects the payload, so dragging
a project across a task row still reaches the pane underneath.
3. Context menus and row interactions are shared code that Android ignores
Section titled “3. Context menus and row interactions are shared code that Android ignores”ContextMenuArea is desktop-only. Everything it is made of is not:
PointerEvent.buttons.isSecondaryPressed is common API, and DropdownMenu anchors anywhere. So
Modifier.secondaryClickable and the three row menus live in :ui beside every other component,
fire on the desktop, and are inert on a touchscreen that has no secondary button.
Reaching them from seven screens without changing seven signatures is a composition local:
RowInteractions (and RowSelectionState beside it). The desktop shell provides them, TaskRow
picks them up, and :app-android — which provides nothing — draws exactly the rows it drew
before. A local is the wrong default for most things; it is right here because the behaviour is
per-row, identical everywhere, and wanted by one shell.
Submenus are pages of the same menu, not cascades: Material 3 has no cascading DropdownMenu,
and a page that names where it is and how to get back is both less code and easier to hit with a
mouse than a strip that closes when the pointer leaves it.
4. Keyboard selection is read off the screen, not off a list
Section titled “4. Keyboard selection is read off the screen, not off a list”Nothing in :ui has “the list”. Seven screens draw rows in bands, under headings, with checklists
spliced in, and two of them draw the same task twice in different roles. So RowSelectionState
does not index into anything: rows register where they are and the order is read off their
vertical positions. That is the order the user sees, and the only one that stays right across
bands, headers and expansion. A row that scrolls away or is deleted takes the selection with it —
otherwise the next x completes something nobody can see.
5. One shortcut table, two readers
Section titled “5. One shortcut table, two readers”Shortcuts.kt is data. The window dispatches from it and the ? sheet is generated from it, so a
shortcut cannot be wired but undocumented, or documented but dead. Ctrl versus Cmd is decided
once, by reading the OS, rather than at fifteen call sites.
6. The sidebar replaced the rail, and the Projects screen stayed
Section titled “6. The sidebar replaced the rail, and the Projects screen stayed”A rail of four icons is a phone’s bottom bar stood on its side. The sidebar is the views plus the whole project tree, with counts, foldable, resizable, and a drop target on every row — which is the part a rail could never be, and the reason the sidebar exists at all rather than being a wider rail.
The Projects screen stays. It is where a project is created, edited and deleted, and those are dialogs that want a page behind them rather than a 268dp column.
7. Two panes are one navigator at two widths
Section titled “7. Two panes are one navigator at two widths”DesktopNavigator holds a back stack, a forward stack (which NavHost never gave us) and the
detail pane’s occupant. go() puts a detail route in the pane when the window is wide and pushes
it as a destination when it is narrow; back() undoes whichever happened. Crossing the threshold
by resizing moves an open detail between the two rather than dropping it. That is what keeps Esc
and Alt+← meaning one thing while a window is being dragged wider.
The screens know none of this: TaskDetailScreen is the same composable in a pane and in a
window.
8. The desktop’s own window state is a second file
Section titled “8. The desktop’s own window state is a second file”workspace.json next to settings.json: sidebar width, which projects are folded, window bounds.
Not in CadenceSettings, which is the shared, ported settings object both shells implement — none
of this means anything on a phone, and adding it would give Android’s SharedPrefsSettingsStore
fields it can never set. Every value is clamped on read: a window position from a monitor that is
no longer plugged in is a window nobody can find.
Consequences
Section titled “Consequences”sortOrderis now a value the app writes. A new list or a new “add” path has to give a row a position, or it lands at 0 among everything else that never got one.- A new mutation still has to call
armSync()(ADR 0002, decision 11); the reorder methods andduplicateTaskdo. - Any new drop must be expressible as a
DropTargetand resolved inresolveDrop. A screen that handles a drop itself is a rule that no test covers. - Android is unaffected by every one of these, and that is a property worth protecting:
assembleDebugcompiling is not enough, since the shared screens now read two composition locals that Android leaves at their defaults.
What is still open
Section titled “What is still open”Multi-select (a second selection model on top of §4), a per-window “focus mode”, touch-gesture
parity on Android, and the desktop language setting ADR 0001 decision 9 deferred — CadenceSettings
still has no language field, and Main.kt still passes Locale.getDefault().