Give the phone the + it was promised, and the editors behind it

Steps 5 and 6 of docs/adding-hosts-on-the-phone.md, which finishes it. The
phone can now put things in a keychain rather than only read one.

The floating + arrives three designs after it was first asked for. It was
refused twice on the honest grounds that it would open an editor this head had
not got — and that stopped being true when steps 1 to 4 landed. It raises a
sheet rather than a form, because "add" on this screen has been two operations
since groups existed: a host, or a group to file hosts under.

Both editors are cards in the list's own row, swapped for the list rather than
stacked over it, following SnippetsScreen and for the reason written there: a
form on top of the list hides what it is about. There is still no dialog and no
editor screen anywhere on this head. The port and username boxes may be left
empty, and the greyed text inside each is what the host will actually use —
following the group picker as it moves, so the form says what leaving a box
blank will do rather than making it a guess.

The back gesture gains a guard above the switch rather than a case inside it.
An editor is not a screen and has no entry there, and it is strictly nearer:
the sheet sits over the editor's own screen, so back lowers whatever is topmost
before it considers moving between screens.

A group editor needs a way in, and the phone draws no groups panel — so the
heading in the host list gains a pencil. A + that adds groups with no way to
correct one is the same strange thing to ship as one that adds hosts with no
way to correct one, and worse: a group's defaults are what every host beneath
it falls back to.

Scouting step 6 turned up a bug worth more than the prose it was looking for.
ReloadGroupsAsync read the active vault alone, which was a cosmetic limitation
while a group carried only a name — a teammate's host showed under UNGROUPED
and nothing else was lost. Since a group began lending a port, a username and a
binding, the same omission silently dropped all three: that host would dial 22
as nobody while the machine is on 2222 as deploy, with nothing on screen saying
why. The resolution map now spans every readable vault. The editable list stays
narrow, because a row shown across vaults needs a vault id for rename and
delete and two vaults may hold groups with one name — but the map needs none of
that, since it is only ever asked what an id says.

An adversarial review of this change found something I had written a comment
denying. The + was a child of the outer Panel rather than of the list's row, so
it anchored to the bottom of the screen and sat squarely over the new EDIT
button and the lower edge of CONNECT — and because a Panel hit-tests its
last-declared child first, tapping the right end of EDIT would have raised the
add sheet. The comment beside it claimed it was in the list's row precisely so
that could not happen, and manual-checks 8.1 names that exact failure. It is in
Grid.Row 2 now. The same pass caught a connect bar that was disabled rather
than hidden while its own comment said hidden, a scrim that would have flashed
its default pressed chrome across the screen, a fab comment claiming an accent
fill no other button had when Button.primary has it, and a back-gesture remark
naming two view-model properties that do not exist anywhere in the repo.

That last one came from this plan, which invented IsVaultsSurface and
IsConnectionsSurface, and pointed step 6 at a ConnectionsScreen.axaml that has
never existed. Both are corrected in the plan rather than quietly worked
around, along with a fourth "groups are flat" site in README.md that step 1
missed.

The phone's rectangles are not measurable and structurally never will be: the
layout suite is net10.0, the Android head is net10.0-android, and Avalonia's
application is a one-shot process global — so a second head cannot share the
process even if the reference were possible. Everything the sheet and both
editors bind to is shared and is tested headlessly here; the pixels go to
docs/manual-checks.md phase 8, nine checks, which is where this project already
sends what it cannot assert.

Tags remain the one thing asked for that did not ship. Tag is a full item kind
and TagIds merges, encodes and resolves, but no screen draws a chip or offers
to add one, so the tags a client can store are ones nothing here can see. Both
editors carry the set through a save untouched so a client that can set them
does not lose them. Recorded as half-shipped in docs/design-import-gaps.md
rather than quietly left out.

Verified by the whole suite: 1402 tests over nineteen projects, none failing.
Both heads build. Nothing seen on a display — phase 8 is what that costs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 11:30:00 +02:00
co-authored by Claude Opus 5
parent b7335743d9
commit 4925dcf179
11 changed files with 1050 additions and 50 deletions
+19 -12
View File
@@ -4,9 +4,8 @@ The phone can read a keychain and connect through it. It cannot put anything in
plan for the change that fixes that, and it is written to be picked up cold — the decisions, the reasons, the
ordered work, and the traps that are already known.
> **Status: steps 14 built, 56 not started.** The domain and the desktop are done and the phone has not
> been touched. Each built step compiles with the whole suite green, which is the rule the ordering below
> sets.
> **Status: built.** All six steps. Each one compiled with the whole suite green before the next began,
> which is the rule the ordering below sets.
>
> | Step | State | Notes |
> | --- | --- | --- |
@@ -14,12 +13,16 @@ ordered work, and the traps that are already known.
> | 2. The `Tag` item kind | **Done** | Secret, codec, merge, cipher, repository, both registries, EF entity and the generated `AddTagItem` migration. |
> | 3. `HostSecret` grows, `Port` goes nullable | **Done** | `TagSet`, `TagIds`, `Port` as `int?`, `AsksForPassword`, both schema versions, and `HostInheritance` — the resolver. |
> | 4. The shared view model | **Done** | Every choke point below, plus the group editor the plan never assigned a step to and without which nothing could set a default at all. |
> | 5. The phone | Not started | Nothing in `Views/HostsScreen.axaml`, `Theme/Phone.axaml` or `PhoneShell` has changed. |
> | 6. Tests and false prose | Partly done as it went | The guards each step tripped are fixed and the connect path is covered. `docs/design-import-gaps.md` and the three phone files still say the `+` is deliberately absent. |
> | 5. The phone | **Done** | The `+`, the sheet, both editor cards, the back guard and EDIT on the connect bar. |
> | 6. Tests and false prose | **Done** | Plus a fourth "groups are flat" site in `README.md` this plan did not list, and a cross-vault resolution bug the scouting for it turned up. |
>
> **Tags are stored but not editable.** `TagIds` merges, encodes and resolves, and no screen can put one on a
> host yet — the desktop editor carries the set through a save untouched so that a client which can set them
> does not lose them. The chips belong with the phone's editors in step 5.
> **Tags are stored and still not editable, and that is the one thing asked for that did not ship.** `Tag` is
> a full item kind and `HostSecret.TagIds` merges, encodes and resolves — but no screen on either head draws
> a chip or offers to add one, so the tags a client can store are ones nothing here can see. Both editors
> carry the set through a save untouched, so a client that can set them does not lose them to somebody
> editing a port. What is missing is a picker and a chip row, on both heads: the plan's step 5 list does not
> mention them, and building the sheet, both editors and the back guard was already the larger half of it.
> Recorded as half-shipped in `docs/design-import-gaps.md` rather than quietly left out.
>
> **`HostFields.From` was answered by a refusal rather than by threading the resolver into the sync engine.**
> A relay host may not inherit its port; `HostSecret.TryValidate` refuses one that tries. A plaintext column
@@ -50,7 +53,7 @@ Three of those fields do not exist at any layer, and one of them is refused on t
| Asked for | What exists today |
| --- | --- |
| The `+` and the editors behind it | Nothing. `Theme/Phone.axaml` has no `.fab` class, and its comment says an unused style would be "a claim that the control exists somewhere". `HostsScreen.axaml` and `ConnectionsScreen.axaml` both tell the user, in prose on screen, that hosts are created on the desktop and sync down. |
| The `+` and the editors behind it | Nothing. `Theme/Phone.axaml` has no `.fab` class, and its comment says an unused style would be "a claim that the control exists somewhere". `HostsScreen.axaml`'s own v2 note says hosts are created on the desktop and sync down. *(This row named a `ConnectionsScreen.axaml` as a second site. No such file exists or ever has; and the `HostsScreen` statement is an XAML comment, so no phone screen ever rendered that sentence to a user.)* |
| Vault picker | **Built.** `VaultViewModel.TargetVaults` / `SelectedTargetVault` / `HasVaultChoice`, hidden at one vault. The desktop's `VaultScreen.axaml` already draws it. |
| Alias, hostname, port, username, key-or-password, group | **Built**, in the shared `VaultViewModel` host editor — `EditorLabel`, `EditorHostname`, `EditorPort`, `EditorUsername`, `EditorAuthenticationChoices`, `EditorGroupChoices`, `SaveHostCommand`. The phone has never bound any of it. |
| Tags | **Nothing.** `SyncEntityType.Tag = 5` and `HostTag = 6` are reserved slots with nothing behind them. `HostSecret` has no tag field. |
@@ -272,8 +275,10 @@ repo-wide, so any XAML warning fails CI.
`HostKeySheet`, which is exactly why that control lives there.
- **The back gesture needs a new guard**, above the `switch` in `PhoneShell.axaml.cs`, in the shape of the
host-key guard: back should close the sheet or the editor rather than leave the screen. The comment on that
method states an invariant — the two switch cases are the membership tests of `IsVaultsSurface` and
`IsConnectionsSurface` and must stay in step — so add a guard, do not extend the switch.
method states an invariant about the switch cases staying in step with the surface tests — so add a guard,
do not extend the switch. *(This named `IsVaultsSurface` and `IsConnectionsSurface`. Neither exists
anywhere in the repo; the real properties are `IsMoreSurface` and `IsTerminalSurface`, and only the first
is what the switch mirrors.)*
- The editors follow `SnippetsScreen.axaml`: a `Border Classes="card"` in the same grid row as the list,
toggled on `IsEditing`, with the list toggled on `!IsEditing`. There is no dialog, no modal and no
editor-screen anywhere on this head, and the reason is written down — a form stacked over the list hides what
@@ -324,7 +329,9 @@ deliberately absent, and each states a reason that will no longer hold.
- `Theme/Phone.axaml` — the "No floating action button" block.
- `Views/HostsScreen.axaml` — the v2 and v3 notes on the missing `+`.
- `Views/ConnectionsScreen.axaml`"hosts are created on the desktop and sync down", rendered on screen.
- ~~`Views/ConnectionsScreen.axaml`~~**this file does not exist and never did.** The real inventory is
the two above plus `README.md`, `docs/android-port.md` and `docs/design-import-gaps.md`. Neither phone
site was "rendered on screen": both are XAML comments.
- `HostGroupSecret.cs` and `HostGroupSecretTests.cs` — "No parent. Groups are flat."
- `docs/design-import-gaps.md` — the **Add host** row, the two floating-button rows, the "Tag / HostTag still
reserved and unused" paragraph, and the tag-chips and groups-on-a-team's-hosts rows in the Hosts table.
+3 -1
View File
@@ -331,7 +331,9 @@ that appear rarely and matter most. Those are marked **◆**.
- **◆ Conflict log** — what a merge overrode and what it discarded, scrollable, with DISMISS ALL. The merge
is only allowed to pick a winner because this exists.
- *Groups panel:* the groups as chips with host counts, a name box that both adds and renames, delete with
its own confirmation counting the affected hosts.
its own confirmation counting the affected hosts. Since groups gained a parent and the four defaults their
hosts inherit, the panel is a small form rather than a box: a parent picker that excludes the group's own
descendants, and a default port, username and binding beside the name.
**2. Files** — two panes and a queue.
+14 -7
View File
@@ -33,7 +33,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | SFTP: the `LOCAL / REMOTE` toggle | One remote pane. Scoped storage means there is no browsable local filesystem, which `docs/android-port.md` decided before any of this was built. |
> | SFTP: the transfer card, and moving files at all | ◆ **Browsing, opening and deleting only, and the screen says so.** Both transfer commands exist and both work — against the local pane. `QueueDownloads` writes to `Path.Combine(LocalPath, name)`, and `LocalPath` starts at `SpecialFolder.UserProfile`, which on Android is the application's own private directory: a download would report success and leave the file somewhere the person who asked for it cannot open. That is worse than not offering it, because a refusal is visible and a file in `/data/user/0/…` is not. The queue is not drawn either, since nothing on this head can put anything in it. Both return with the document picker. |
> | S3: `96 objects · 412 GB` and `lifecycle: IA after 7d` | Omitted. Neither a bucket total nor a lifecycle policy is fetched; the listing is what `IRemoteFileStore` returns. |
> | A `+` floating button on **HOSTS** | Omitted. It would open a host editor, and this head has none — hosts are created on the desktop and sync down. |
> | A `+` floating button on **HOSTS** | **Shipped in v3.** It was omitted twice, on the grounds that it would open a host editor this head had not got — and that was true until one existed. It raises a sheet rather than a form, because "add" on this screen is two operations: a host, or a group to file hosts under. |
> | A 14px rounded inset around the terminal | Not drawn. The renderer is a native child view composited above everything Avalonia draws, so a rounded frame behind it clips nothing. What the design was after comes from the page's own background, which is the design's `#171A26`. |
> | Instrument Sans / Fira Code | Inter and the system monospace stack, as before and for the same reason — see the note on `MonoFont` in `Palette.axaml`. |
> | A theme toggle (`☀`) in the header, and the desktop v2's light theme | Omitted. There is one theme by decision, and a switch with one position is a control that does nothing. |
@@ -59,7 +59,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | v2 element | What ships instead |
> | --- | --- |
> | The **FORWARDING** screen, the `⇄ N forwards` chip on the session strip, and the host detail's FORWARDING card | Nothing, as on the phone and for the same reason. Three surfaces for a feature the SSH layer does not have. |
> | The host detail's 2×2 card grid — IDENTITY fingerprint, TAGS, LAST SESSION | Not drawn. There is no fingerprint on a stored key, no tag item type, and no last-used timestamp at any layer. The pane keeps the host editor the design has no equivalent of, which is the thing that is actually used. |
> | The host detail's 2×2 card grid — IDENTITY fingerprint, TAGS, LAST SESSION | Not drawn. There is no fingerprint on a stored key and no last-used timestamp at any layer. A tag item type now exists and a host names them, but nothing on either head draws or edits a chip, so the card would have one field of the three and it would be empty. The pane keeps the host editor the design has no equivalent of, which is the thing that is actually used. |
> | The keychain detail's public-key block, USED BY hosts, and **Rotate** | The detail pane as it stands. Copy public key is real and already there; rotation is not a thing this client can do. |
> | Logs: **FOLLOW**, the severity chips, and `tailing via ssh · journald + files` | The two logs it really has. Same as the phone — these are synced audit records, not a tail, and the footer sentence describes a feature rather than the screen. |
> | Snippets: **RUNS ON** host tags and a last-run line | The command and the two buttons that name the terminal they type into. |
@@ -136,8 +136,15 @@ protocol rather than a protocol change.
Two of those slots are now taken. `HostGroup` and `Snippet` shipped as full item kinds — a table, an EF
configuration, a server kind that refuses every plaintext field, a codec, a merge, a cipher and a repository
— and neither needed a contract change, which is what the reservation bought. `Tag`, `HostTag`,
`HostCredential` and `PortForward` are still reserved and still unused.
— and neither needed a contract change, which is what the reservation bought. `Tag = 5` has since shipped the
same way, for the same cost, and is the clearest case the reservation ever paid for: a whole item kind, a
table, a migration and an AAD arm, with `PublicAPI.Unshipped.txt` untouched.
`HostTag`, `HostCredential` and `PortForward` are still reserved and still unused — and `HostTag` is now
reserved *against a decision* rather than pending one. Which hosts wear a tag is a set of ids inside each
host's payload, not a join: the one thing a join buys over that is two machines tagging the same host
without either losing, and the client's per-key three-way merge already gives it. A second item type would
be a second table and a second pass through every rule in this file, for nothing.
**A third was added for something the design never mentioned**: `ObjectStore = 13`, an S3-compatible bucket
and the keys that reach it. It is a keychain item like any other — the endpoint and the secret access key are
@@ -198,10 +205,10 @@ caption buttons and window title drawn on top of the application's own — two s
| Design element | Layer | What it would take | What ships instead |
| --- | --- | --- | --- |
| Tag chips (`nginx`, `eu`, `pg16`) | client-domain | A tag item type and a host-tag join. Both reserved on the wire (`Tag = 5`, `HostTag = 6`), neither implemented, plus a payload schema bump on `HostSecret`. | Omitted. The filter box searches name, address and notes instead. |
| Groups `PRODUCTION` / `STAGING` / `PERSONAL` | client-domain | A host-group item type (`HostGroup = 4`, reserved) or a group field on `HostSecret`. | **Shipped**, as both: `VaultHostGroup` is a synced item kind and `HostSecret.GroupId` names one. Flat, not nested. A keychain with no groups renders exactly as it did before — one flat list, no headings. |
| Tag chips (`nginx`, `eu`, `pg16`) | client-domain | A tag item type, and a way to put one on a host. | **Half shipped.** `Tag = 5` is a live item kind and `HostSecret.TagIds` names them, merged per tag so two people tagging one host both keep theirs — which is what `HostTag = 6` was going to buy, so it stays reserved and unused. No chip is drawn or edited on either head yet, so the tags a client can store are ones nothing here can see. The filter box searches name, address and notes. |
| Groups `PRODUCTION` / `STAGING` / `PERSONAL` | client-domain | A host-group item type (`HostGroup = 4`, reserved) or a group field on `HostSecret`. | **Shipped**, as both: `VaultHostGroup` is a synced item kind and `HostSecret.GroupId` names one. Nested in the data and drawn flat — a group carries a `ParentId` and the defaults its hosts inherit, and the sidebar still emits one heading per group in label order with no indentation. A keychain with no groups renders exactly as it did before — one flat list, no headings. |
| Group badge `TEAM·PLATFORM` | server | **Built in M3.** | The vault's name on each row, and the personal vault ordered first. Not the team's name: two of a team's vaults would then carry the same badge and the badge would be naming the wrong thing. Distinct from the groups above, and deliberately so — a group is a shelf the user chose, a vault is who can read the item. |
| Groups on a **team's** hosts | client-domain | Reading groups across every readable vault, a vault id on each group row for rename and delete, and a way to tell two vaults' identically-named groups apart in a list with one heading per group. | Not yet. Groups are read from the active vault alone, so a host a teammate filed shows under UNGROUPED — the same way a host whose group was deleted does. Nothing is lost or misfiled; the grouping is simply not shown. |
| Groups on a **team's** hosts | client-domain | A vault id on each group row for rename and delete, and a way to tell two vaults' identically-named groups apart in a list with one heading per group. | **Half shipped, and the half that shipped had to.** Every readable vault's groups are now read into the resolution map, because a group lends a port, a username and a binding — so a host whose group went unread would silently dial 22 as nobody while the machine is on 2222 as `deploy`. A missing heading is cosmetic; a missing port is a connection to the wrong place. The editable list is still the active vault's alone, so a host a teammate filed still shows under UNGROUPED and cannot be renamed or unfiled from here. |
| Per-host status dot, three colours | client-ssh | The amber state would mean "reachable but not connected", and nothing here ever probes a host. | Two states, both real: green when a terminal is open on that host, grey when not. |
| `· ⤷ bastion-eu` in the host subtitle | client-ssh | **Jump hosts are data-only.** `HostSecret.JumpHostIds` is a `JumpChain` that is stored, encrypted, synced and three-way merged — and nothing reads it at connect time. `SshConnectionRequest` carries one host. | Omitted. The stored chain is preserved untouched by every edit. |
| `SPLIT ⌘D` and side-by-side panes | client-ssh + ui | The renderer stacks panes and shows one (`terminal.css`: `.pane { position:absolute; inset:0; display:none }`). Tiling needs a real pane geometry and a splitter. | Omitted. Tabs ship instead, over the same one-WebView multiplexing. |
+98
View File
@@ -691,3 +691,101 @@ add a bucket — then come back.
**Failure means:** the screen has gone back to copying the vault's lists once at unlock. Covered by
`TheTransfersScreen_FollowsTheVaultsHostList`; this is the version of it with a real picker in front of it.
## Phase 8 — Adding hosts on the phone
Nine checks, and the reason there are nine rather than none is worth stating: **the layout suite cannot see
any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and
`DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's
application, dispatcher and platform are one-shot process globals, so a second head cannot share the
process either; and the phone's numbers only mean anything measured under the phone's own style graph.
Every property and command below is covered headlessly in `ShellFlowTests` — the flow is shared, and only
the rectangles are not. So what follows is the rectangles.
Run on a 360dp-wide device or emulator, unlocked, on HOSTS.
### 8.1 The + is reachable and is not covering anything
Look at the bottom right of the host list with no host selected.
**Pass:** a filled accent circle, 56×56, clear of the bottom edge and of the last row's text. Then select a
host so the connect bar rises.
**Pass:** the + has moved up with the bar and is not over the CONNECT button.
**Failure means:** the + was anchored to the screen rather than to the list's row. Nothing in the layout
harness would catch it if it could run — its only fit rule is containment inside the window, never overlap
between two controls, so a button sitting squarely on top of CONNECT reports clean.
### 8.2 The sheet says what each choice does
Tap the +.
**Pass:** a sheet rises from the bottom with a grab handle, two rows — **New host** and **New group** — each
with a sentence under it, and CANCEL. The scrim darkens the list.
**Note:** the four nav destinations underneath stay lit and tappable. That is deliberate — this is a menu,
not a decision, and navigating away is a fine way to answer it. Compare the changed-host-key screen, which
must offer no way out but BACK.
### 8.3 The sheet is dismissible three ways
Tap the scrim. Reopen, tap CANCEL. Reopen, use the system back gesture.
**Pass:** all three lower it, and none of them opens an editor.
**Failure means:** back is being handled by the screen switch rather than by the guard above it, and the
gesture has left HOSTS instead of closing the sheet.
### 8.4 An empty port box says what it will dial
Add a group with a default port of 2222, then tap + → New host and choose that group in the GROUP picker.
**Pass:** the port box is **empty**, and the greyed text inside it changes to `2222` the moment the group is
chosen. The username box behaves the same way.
**Failure means:** the editor loaded a resolved value into the box instead of a placeholder behind it —
saving would then pin 2222 onto the host, and changing the group later would leave that host behind.
### 8.5 A host created that way inherits rather than copies
Save that host, then edit the group's default port to 2200 and look at the host's row.
**Pass:** the address now reads `…:2200`. Connect: the session goes to 2200.
**Failure means:** the port was copied at creation and the group is a one-shot template.
### 8.6 Filing a host into a group does not silently pin its password
Add a group with a default SSH key. Add a host with no group and leave AUTHENTICATION on **Password (ask
each time)**. Save. Now edit it and choose the group.
**Pass:** the picker gains an **Inherit from group** entry and moves to it. Save, and the host row reads
`key`, and the connect bar shows a note naming the group rather than a password box.
**Failure means:** the picker's "Password (ask each time)" was written as a decision when it was only the
absence of one, and this host will never use the fleet's key.
### 8.7 A host can still refuse its group's key
On that same host, set AUTHENTICATION back to **Password (ask each time)** and save.
**Pass:** the connect bar shows a password box again, and it survives a lock and unlock.
**Failure means:** the fourth state has no storage and the host is inheriting whatever the group says.
### 8.8 Back closes the topmost thing, one at a time
Tap + → New host, then use the back gesture.
**Pass:** the editor closes and the list is back, with the boxes cleared. Back again leaves the application
— HOSTS is the screen the app opens on and back from it should exit, as it always did.
### 8.9 Every target is a thumb's size
Walk the sheet's two rows, the editor's fields, both pickers, the checkbox and the four buttons.
**Pass:** nothing is under 44 effective pixels tall.
**Failure means:** a control took its size from its glyph. The desktop suite's only tap-target assertion has
a floor of 20 pixels — set for a mouse — so copying it here would certify a target no thumb can hit.