Skip to content

feat(web,mobile,server): image icons, mobile picker, and container detection - #5

Open
amanthanvi wants to merge 24 commits into
env-icons/05-lucidefrom
env-icons/06-image-mobile-detect
Open

amanthanvi wants to merge 24 commits into
env-icons/05-lucidefrom
env-icons/06-image-mobile-detect

Conversation

@amanthanvi

@amanthanvi amanthanvi commented Sep 20, 2026 •

Copy link
Copy Markdown
Owner

What changed

Three things were still missing: an uploaded image, a way to pick on mobile, and detection for the machines left as a generic server.

  • Image icons store a capped data: URL. Both clients already render inline images, so there is no new route, blob store, or access check. Each client crops to a square and downscales to 64 by 64 natively before writing (a canvas on web, expo-image-manipulator on mobile) and validates the result against the schema. The contract pins the prefix to PNG, which is what both encoders ask the canvas for. An SVG can script, and on web the declared type is what picks the decoder, so the write refuses one. The web dialog holds Save while an image encodes, so a replacement picked just before saving is not dropped in favor of the old image.
  • The mobile picker lands in the expanded body of the environment row, beside the label and URL fields, as a sheet listing the curated icons and a photo option. Settings → Environments now opens a page per environment, and that page shows the row under Connection. Emoji and monograms are entered on web, where there is a keyboard; mobile draws whatever was picked there and says so. The Icon field locks until the environment is connected and when the session lacks the settings-write grant (AuthSettingsWriteScope), using the same lock and messages as web, and only an expanded row checks the session.
  • Containers: nothing read /.dockerenv, /run/.containerenv, or PID 1's cgroup, so a container fell through the chassis table. It now detects as the new container kind, checked before both the WSL kernel and the host's DMI, since a Docker Desktop container runs on the WSL 2 kernel. PID 1's cgroup counts only when it names a runtime, such as a Docker scope, a kubepods path, or lxc. Under cgroup v2 a container with a private cgroup namespace sees a bare 0::/, but so do WSL 2 and any host whose init leaves PID 1 in the root cgroup, so that value alone says nothing. Such a container is detected only through /.dockerenv or /run/.containerenv. Otherwise it falls back to the host's hardware, and a pick fixes it.
  • container joins the machine kinds a server can detect, but only the seven original kinds have a bare-string wire form. A pick of it travels as the object like any role, so a server without environmentIconOverride locks it and an older client drops it rather than choking on an unknown string.
  • Windows: detection returned null by design. It now reads the same SMBIOS fields through one PowerShell CIM call and classifies them with the Linux DMI rules. The call is budgeted at 1.5 s like the terminal's existing CIM probe, and boot runs it alongside the label and launcher probes rather than after them. The macOS probes keep main's 5 s. A missing enclosure keeps the vendor fields usable, so a Hyper-V guest still reads as cloud.
  • Apple silicon model identifiers name a generation rather than a product line; they resolve through a table so Mac16,10 is a Mac mini on the hw.model fallback path.
  • Cloud VMs already detected as cloud through eighteen markers. Naming the vendor is left alone, because it would widen the contract to add one label.
  • The mobile picker keeps its own choice list, because a phone has no place to type a monogram and the two lists offer different things. What the two did share was the rule for what a curated pick stores, copied four times across the two clients with only one copy explaining the wire form. That rule moved into contracts as environmentIconForCuratedId at layer 4, so both pickers call it here.
  • The T3 Connect discovery rows get no icon control on purpose, and the reason is recorded in the code where the next reader will look for one. The only place an icon is stored is that machine's own settings, so a pick made before connecting would live on this device alone and would not follow the user to their other devices.

Why

Stacked on #4. Layer 6 of 6; the rest of the scope from the original request.

Opened on the fork because GitHub only accepts a base branch that lives in the base repository, and stacks cannot span a fork and its upstream. Layers 2 through 6 form a native stack on the fork, so merging one layer there rebases the rest. Each will be re-targeted to pingdotgg/t3code once its base merges. The entry point upstream is pingdotgg#15511.

UI changes

image dialog

after image

Verification

  • In the web client, a 64 by 64 PNG uploaded, saved as data:image/png;base64,… at 806 characters against a 32,768 cap, and rendered as an <img> in the row.
  • ServerEnvironmentMachine.test.ts covers the Windows probe (success, a missing enclosure, failure, and garbage output, with the script's array wrap and JSON flags pinned), container detection by marker file and by a runtime-named cgroup path, a bare cgroup v2 root not counting as one, and detection ahead of the WSL kernel check, and the Apple silicon table.
  • The picker rules web and mobile share (the lock, the object-form gate, and clearing on the detected kind) live in @t3tools/client-runtime/environment-icon, covered by environmentIcon.test.ts. Mobile environmentIconPicker.logic.test.ts covers the choice list gating.
  • EnvironmentIconPicker.test.ts refuses an SVG data URL and a missing image.
  • Typecheck green for contracts, server, web, and mobile.

Two things have not run on real hardware, the mobile sheet and photo picker on a device, and the Windows probe on a Windows host. The tests mock the process runner.

An adversarial review of this layer found and fixed, before opening: picking Container was no longer capability-gated and would have failed against every released server; Docker Desktop containers were read as WSL because the kernel check ran first; the cgroup markers were dead under cgroup v2; the Windows probe held boot for up to 5 s; a null enclosure discarded a usable vendor answer; and the web downscale ran at the default low smoothing. It also found the web icon component cache keying on the 32 KB data URL, which layer 4 now rules out by keying that cache on the icon object instead.

Claude Fable 5.1 via Claude Code

Summary by Sourcery

Add image-based environment icons, mobile icon selection, and broader machine detection while maintaining compatibility with existing servers and clients.

New Features:

  • Add web and mobile support for selecting, storing, and rendering cropped 64×64 PNG image icons for environments.
  • Add a mobile environment icon picker with curated icons and photo selection, including capability-aware editing controls.
  • Detect container environments on Linux and machine kinds on Windows, and classify additional Apple silicon models.
  • Support the container machine kind in environment icon contracts while preserving compatibility with older clients and servers.

Bug Fixes:

  • Prevent image selections made during asynchronous encoding from being lost when saving the web dialog.
  • Prioritize container detection over WSL and host hardware detection, and preserve Windows vendor signals when enclosure data is unavailable.

Enhancements:

  • Centralize curated environment icon conversion across clients and gate richer icon choices by server capabilities.
  • Document environment icon behavior across web and mobile, including the absence of controls on undiscovered connection rows.

Documentation:

  • Update user documentation for container and Windows detection, image icons, and mobile environment icon selection.

Tests:

  • Add coverage for image validation, mobile picker behavior, container and Windows detection, and Apple silicon model resolution.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @amanthanvi, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 1 day and 11 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Adds bounded image icons across web and mobile, a capability-aware mobile picker, and broader server machine detection for containers, Windows, and Apple silicon, with compatibility-preserving contract changes and tests.

Sequence diagram for selecting and saving an environment image icon

sequenceDiagram
    actor User
    participant Picker as ImagePicker
    participant Encoder as ImageEncoder
    participant Contract as IconImageDataUrlSchema
    participant Settings as EnvironmentSettings
    participant Server as EnvironmentServer

    User->>Picker: Choose image
    Picker->>Encoder: encodeEnvironmentIconImage(file)
    Encoder->>Encoder: Crop and resize to 64x64 PNG
    Encoder->>Contract: Validate data:image/png data URL
    Contract-->>Encoder: Valid or invalid
    Encoder-->>Picker: Encoded result
    User->>Picker: Save
    Picker->>Settings: updateSettings(environmentIcon)
    Settings->>Server: Persist icon on machine
    Server-->>Settings: Updated environment settings
Loading

Entity relationship diagram for environment icon compatibility

erDiagram
    SERVER_ENVIRONMENT ||--o| ENVIRONMENT_ICON : stores
    SERVER_ENVIRONMENT {
        string environmentIcon
        string environmentIconOverride
        string machine
    }
    ENVIRONMENT_ICON {
        string kind
        string name
        string dataUrl
    }
    ENVIRONMENT_ICON ||--o| LEGACY_MACHINE_KIND : bare_string_wire_form
    ENVIRONMENT_ICON {
        string compatibility_form
    }
    LEGACY_MACHINE_KIND {
        string name
    }
Loading

Flow diagram for capability-aware mobile environment icon selection

flowchart TD
    A["Expand connected environment"] --> B["Tap Icon"]
    B --> C{"environmentIcon capability?"}
    C -->|No| D["Show locked picker"]
    C -->|Yes| E{"Rich icon override supported?"}
    E -->|No| F["Enable legacy machine kinds only"]
    E -->|Yes| G["Enable curated icons and photo"]
    F --> H["resolveMobileEnvironmentIconWrite"]
    G --> H
    H --> I["updateSettings"]
Loading

File-Level Changes

Change Details Files
Added end-to-end image icon support with bounded, validated PNG data URLs.
  • Crop and downscale selected images to 64×64 on web and mobile before storing them inline.
  • Reject unsupported or invalid image data and prevent saving while web encoding is pending.
  • Render image icons in both clients and preserve cache behavior for large data URLs.
apps/web/src/components/EnvironmentMachineIcon.tsx
apps/web/src/components/settings/EnvironmentIconPicker.logic.ts
apps/web/src/components/settings/EnvironmentIconPickerDialog.tsx
apps/web/src/components/settings/EnvironmentIconPicker.test.ts
apps/web/src/lib/environmentIconImage.ts
apps/mobile/src/components/EnvironmentMachineSymbol.tsx
apps/mobile/src/lib/environmentIconImage.ts
packages/contracts/src/icon.ts
Introduced the mobile environment icon picker and shared curated-icon write semantics.
  • Added an environment-row picker sheet with curated choices, photo selection, accessibility states, and capability gating.
  • Moved curated icon conversion into contracts and reused it from mobile picker logic.
  • Keep discovery rows control-free because icons are persisted only in the connected machine's settings.
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx
apps/mobile/src/features/connection/EnvironmentIconPickerSheet.tsx
apps/mobile/src/features/connection/environmentIconPicker.logic.ts
apps/mobile/src/features/connection/environmentIconPicker.logic.test.ts
apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx
packages/contracts/src/environment.ts
Expanded server machine detection for containers, Windows, and Apple silicon.
  • Detect containers from marker files and named PID 1 cgroups before WSL and DMI detection.
  • Probe Windows enclosure and computer-system data through one bounded PowerShell CIM call while retaining vendor-based cloud detection when enclosure data is missing.
  • Map Apple silicon model identifiers to machine kinds and add focused detection coverage.
apps/server/src/environment/ServerEnvironmentMachine.ts
apps/server/src/environment/ServerEnvironmentMachine.test.ts
Updated compatibility handling and user-facing documentation for new icon and machine kinds.
  • Keep legacy machine kinds compatible as bare strings while serializing newer kinds such as container in object form.
  • Document image icons, mobile selection, expanded detection, and server-side persistence behavior.
  • Add contract coverage for container object-form encoding.
packages/contracts/src/environment.ts
packages/contracts/src/settings.test.ts
docs/user/project-settings.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XL labels Sep 20, 2026
@amanthanvi
amanthanvi added this pull request to stack #6 September 20, 2026 09:26
@amanthanvi
amanthanvi force-pushed the env-icons/06-image-mobile-detect branch from f99eab5 to fe267c1 Compare September 20, 2026 09:33
@github-actions

github-actions Bot commented Sep 20, 2026 •

Copy link
Copy Markdown

Thread transfer impact

⚠️ The latest CI run did not produce a thread transfer result for 75fd8e1.

This comment will update automatically after the next completed run.

@amanthanvi
amanthanvi force-pushed the env-icons/06-image-mobile-detect branch from fe267c1 to 3ef97b6 Compare September 21, 2026 02:58
@amanthanvi
amanthanvi force-pushed the env-icons/06-image-mobile-detect branch from 3ef97b6 to 8e6149b Compare September 21, 2026 03:02
@amanthanvi
amanthanvi force-pushed the env-icons/06-image-mobile-detect branch from 8e6149b to 2b6c084 Compare September 21, 2026 04:16
@amanthanvi
amanthanvi force-pushed the env-icons/06-image-mobile-detect branch from 2b6c084 to 0011cfe Compare September 21, 2026 04:25
@amanthanvi
amanthanvi force-pushed the env-icons/06-image-mobile-detect branch 2 times, most recently from 045d9aa to e1a82ee Compare September 21, 2026 05:17
@amanthanvi

Copy link
Copy Markdown
Owner Author

Two commits pushed, head is now e1a82ee6a1.

The encoder reports a failed encode instead of throwing (075b8441dd)

encodeEnvironmentIconImage documents a result type as its error channel and every other exit uses it, but canvas.toDataURL reports by throwing and the enclosing try carries only a finally. EnvironmentIconPickerDialog.tsx:167 awaits the call with no catch, so a throw there leaves the dialog on a pending upload with no error and no way forward.

It now returns the existing unreadable reason, which the dialog already renders. A 64 by 64 canvas drawn from a same origin Blob should not taint or overflow, so this is about the function honoring the contract its caller relies on, not a failure anyone has hit.

Three comments described code that does not exist (e1a82ee6a1)

IconImageDataUrl said both encoders draw through a canvas. Only web does. Mobile hands the file to expo-image-picker and re-encodes through the platform image APIs. The conclusion survives, since neither route emits an APNG, so the sentence now names both mechanisms instead of one.

The 16 MiB source guard was written as a bound on decoded pixels. It bounds compressed bytes, and a dense 16 MiB PNG still decodes to more than the comment implied. It is a coarse proxy and now says so.

EnvironmentMachineIcon said an inline image has no fallback state because nothing loads. Nothing loads over the network, but the schema checks the PNG signature and not the pixels, so bytes a peer wrote by hand can fail to decode and draw as an empty box. I did not add an onError fallback: this component renders once per row at 12 pixels in long lists, and the case is only reachable from a malformed peer write. The comment records the case and the trade instead of denying the case exists.

Verification

vp test run on the contracts settings tests and the environment icon picker tests: 160 pass. Typecheck clean on apps/web. Rebased onto 2679f87dea.

@amanthanvi
amanthanvi force-pushed the env-icons/06-image-mobile-detect branch from e1a82ee to a240054 Compare September 21, 2026 23:45
@amanthanvi

amanthanvi commented Sep 21, 2026 •

Copy link
Copy Markdown
Owner Author

Rebased onto current main

Rebased the whole stack onto main at 76cc9b08f1, which was 52 commits past the previous base. All 20 commits replayed with no conflicts, and 12 of the stack's 74 files fall in the region main touched.

git range-diff against the pre-rebase tips shows 19 of the 20 commits carry an identical patch. The one that differs is refactor(web,mobile): environment renderers take the resolved icon, and the difference is context only. Main added closeOnClick to the environment MenuRadioItem in BranchToolbar.tsx, two lines above the prop this stack renames. Both changes are present in the rebased file.

A clean textual replay does not prove the stack still holds together, so I went looking for the hazard that would hide behind one. Layer 2 renames resolveEnvironmentMachineKind across 40 files, so a call site added upstream would compile against a symbol this stack deletes. The 52 commits add none.

Each layer is its own pull request, so I verified each one standing alone rather than only at the tip:

layer typecheck tests
01 contracts 4 packages, 0 errors 7 files, 290 tests
02 rename 5 packages, 0 errors 7 files, 291 tests
03 curated 5 packages, 0 errors 7 files, 296 tests
04 rich 5 packages, 0 errors 9 files, 303 tests
05 lucide 5 packages, 0 errors 10 files, 307 tests
06 image, mobile, detect 6 packages, 0 errors 11 files, 321 tests

No review thread was open when I rebased, so the force push moved commits rather than answers. Line comments from earlier rounds now anchor to the old SHAs.

@amanthanvi

Copy link
Copy Markdown
Owner Author

@sourcery-ai review

@sourcery-ai

sourcery-ai Bot commented Sep 21, 2026

Copy link
Copy Markdown

Sorry @amanthanvi, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 28 minutes by commenting @sourcery-ai review. Upgrade to get a review now.

@amanthanvi
amanthanvi force-pushed the env-icons/06-image-mobile-detect branch from a240054 to 0e38392 Compare September 22, 2026 10:54
@amanthanvi

Copy link
Copy Markdown
Owner Author

Rebased onto current main, and the monogram counter lost its Intl.Segmenter branch

Rebased the stack onto main at aff9318bf4, four commits past the previous base. All 35 commits replayed with no conflicts, and 3 of the stack's 76 files fall where those four commits landed.

Those three overlaps are ConnectionsSettings.tsx, PullRequestDetailPanel.tsx, and _chat.pull-requests.tsx, and main's edits to them swap className="size-3.5" on Spinner and RefreshIcon for the new size prop. None of it touches the icon code this stack changes, and the stack adds no Spinner or RefreshIcon call site that should be using the new prop.

One thing in those four commits does bear on the stack. refactor(web): drop className overrides that repeat the base styles lowered RESTYLE_CEILING from 1247 to 1207, and main now sits at exactly 1207 with no slack, so any restyle finding this stack added would fail the gate. Each of the six layers measures exactly 1207.

The monogram count is now the same on every client

isMonogramLength in contracts and firstGrapheme on mobile each reached for Intl.Segmenter behind a typeof guard and counted code points otherwise. t3code/no-hermes-unsupported-apis is configured at error severity for packages/contracts/src/** and apps/mobile/src/** (vite.config.ts:195), and it reports the construction rather than the reference, so the guard did not satisfy it. Both branches are gone.

I deleted them rather than suppressing the rule, because the rule was right in both files. Hermes ships no segmenter, so the contracts guard accepted on the server and on web a monogram that mobile refused, and a validation bound that depends on the runtime reading it is the wrong shape for a bound. The mobile file only ever runs on Hermes, so its segmenter branch was dead in the app and live only in vitest on Node, which left three tests covering a path that never shipped.

The cost is real and worth naming. A two-cluster Devanagari conjunct or a decomposed Hangul syllable counts high, so those scripts get one cluster in a two-character monogram. In exchange every client agrees on what it will store, and the two tiles show exactly what the picker agreed to.

The write-schema split survives the change for a reason that never depended on the runtime. A decode-time bound would send a longer stored monogram through ForwardCompatibleNullable to null, and the user would get the detected glyph with nothing saying why.

Per-layer verification

Each layer is its own pull request, so each was verified standing alone rather than only at the tip.

layer typecheck lint tests restyle
01 contracts 4 packages, 0 errors 0 errors 4 files, 245 tests 1207
02 rename 5 packages, 0 errors 0 errors 5 files, 276 tests 1207
03 curated 5 packages, 0 errors 0 errors 6 files, 285 tests 1207
04 rich 5 packages, 0 errors 0 errors 9 files, 296 tests 1207
05 lucide 5 packages, 0 errors 0 errors 10 files, 300 tests 1207
06 image, mobile, detect 5 packages, 0 errors 0 errors 12 files, 327 tests 1207

The force push moved commits, so line comments from earlier rounds now anchor to the old SHAs.

@amanthanvi

Copy link
Copy Markdown
Owner Author

@sourcery-ai review

@github-actions github-actions Bot added size:XXL and removed size:XL labels Oct 6, 2026
@amanthanvi

Copy link
Copy Markdown
Owner Author

Rebased onto main 64275ae396

Main moved about 115 commits. Fixes that came out of the rebase:

Three independent review rounds over the rebased stack found these, now fixed in the layer that owns the code:

  • Layer 2: T3 Connect rows, which main now shows for machines saved over another route, show the icon the user picked on web and mobile. A test from main that the stack had dropped is back: an unknown detected kind decodes as absent.
  • Layer 6: the icon picker locks on a dropped connection and on a session that cannot change settings, on both clients, through one shared lock. On mobile, only an expanded row checks the session. The mobile sheet no longer marks a colored icon as its plain row, where a tap would have dropped the color.
  • Layer 6: a Mac Studio with M3 Ultra (Mac15,14) detects as a workstation. Comments that said every machine kind has a string form now say legacy machine kind, since container has none.

Every layer passes typecheck, lint, format, and its own tests.

…tection

Three things were still missing after the picker learned emoji, monograms,
and colors: an uploaded image, a way to pick on mobile, and detection for
the machines that were left as a generic server.

Image icons store a capped `data:` URL. Both clients already render inline
images, so there is no new route, blob store, or access check. Each client
crops to a square and downscales to 64 by 64 natively before writing (a
canvas on web, `expo-image-manipulator` on mobile), and validates the
result against the schema so it writes exactly what the server accepts.
The prefix is pinned to PNG. An SVG can script, so the encoder never
produces one and the write refuses it.

The mobile picker lands in the expanded body of the environment row, beside
the label and URL fields, as a sheet listing the curated icons and a photo
option. Emoji and monograms are entered on web, where there is a keyboard;
mobile draws whatever was picked there and says so. Writes go through the
same settings command the settings screens use.

Detection gains the two real gaps. Nothing read `/.dockerenv`,
`/run/.containerenv`, or PID 1's cgroup, so a container fell through the
chassis table. A container now detects as the new `container` kind,
ahead of the host's DMI. Windows detection returned null by design, and
it now reads the same SMBIOS enclosure table and hypervisor strings
through one PowerShell CIM call. Apple silicon model identifiers, which
name a generation rather than a product line, resolve through a table so
`Mac16,10` is a Mac mini. Cloud VMs already detected as `cloud` through
eighteen markers, so naming the vendor is left alone. That would be a
contract widening to add one label.

The T3 Connect discovery rows get no icon control on purpose, recorded in
the code where the next reader will look for one. The only place an icon
is stored is that machine's own settings, so a pick made before connecting
would live on this device alone and would not follow the user to their
other devices, which is the one property the icon exists for.

Claude Fable 5.1 via Claude Code
`isContainerCgroup` treated `0::/` as proof of a container, and the
container branch runs before the WSL check. Under cgroup v2 a container
with a private namespace does read a bare root, but so does any host
whose init leaves PID 1 there: stock WSL 2, Alpine on OpenRC, Void on
runit. Those all reported as containers, and the WSL check below never
ran. Systemd hosts read `0::/init.scope` and were unaffected, which is
why the existing tests passed.

Only a named runtime counts now. Docker and Podman drop a marker file,
and Kubernetes, containerd and LXC name themselves in the cgroup path,
so the only case lost is a raw containerd container with a private
namespace and no marker file, which falls through to the generic glyph.

Alongside it, four things the same review turned up:

Image icons keyed their cached component on object identity, but every
settings snapshot decodes a fresh object, so any settings write anywhere
handed back a new component and remounted the subtree. They key on the
data URL now, which removes the separate WeakMap.

The WebP fallback in both encoders was unreachable. Incompressible noise
at 64 by 64 encodes to 22,050 characters against a 32,768 cap, so the
PNG always fits. Removing it leaves the encoders producing exactly what
the contract accepts, which the first layer now pins to PNG. Its comment
gains the reason the frame count is still open. APNG declares
`image/png`, and only the canvas in these encoders rules it out.

Picking a second image while the first was still encoding could let the
first win. The handler ignores a stale result now. A source over 16 MB
is refused before `createImageBitmap` decodes it at full resolution.

The `machine` field still documented containers and Windows as
undetectable. Both are detected here.

Claude Opus 5 via Claude Code
…size one

The mobile encoder built the data URL from an optional `base64` field with an
empty string fallback, so a save that returned no payload failed the schema
and told the user their image was too big for the cap. Report it as unreadable.
`encodeEnvironmentIconImage` documents a result type as its error channel and
every other exit uses it, but `canvas.toDataURL` reports by throwing and the
enclosing `try` carries only a `finally`. The picker dialog awaits the call
with no `catch`, so a throw there would leave the dialog showing a pending
upload with no error and no way forward.

Contain it and return the existing `unreadable` reason, which the dialog
already renders as a message. A 64 by 64 canvas drawn from a same-origin
`Blob` should not taint or overflow, so this is about the function keeping
the contract its callers rely on rather than a failure seen in practice.
…rong thing

Review caught three claims that do not match the code.

`IconImageDataUrl` said both encoders draw through a canvas. Only web does.
Mobile hands the file to `expo-image-picker` and re-encodes through the
platform image APIs. The conclusion still holds, since neither route can emit
an APNG, so the sentence now names both mechanisms.

The 16 MiB source guard was written as a bound on decoded pixels. It bounds
compressed bytes, which only stands in for pixel count, so a dense 16 MiB PNG
still allocates more than the comment implied. Say that it is coarse.

`EnvironmentMachineIcon` said an inline image has no fallback state because
nothing loads. Nothing loads over the network, but the schema checks the PNG
signature and not the pixels, so bytes a peer wrote by hand can fail to decode
and draw as an empty box. The comment now records that and why an `onError`
fallback is not worth state in a component that renders once per row at 12
pixels.
Comment-only change. Splits four explanations that joined clauses with a
colon into separate sentences.
…a false cgroup claim

An independent minimalism audit found three defects in this layer.

The mobile photo pipeline had no source bound. The web helper caps source
bytes and says why in its doc comment, that `createImageBitmap` decodes at
full resolution so a 40 megapixel photo allocates hundreds of megabytes to
draw a 64 pixel tile. `renderAsync` does the same thing natively, where the
memory is scarcer. The picker reports the dimensions before any decode runs,
so mobile bounds pixels directly rather than estimating them from a
compressed byte count.

Picking a photo never set `pending`, so the row stayed enabled across
the native picker, the decode, and the encode, while the sibling write
guarded itself. The settings write is now its own function and each
caller opens one pending window around the work it does.

The image branch was the only icon kind on mobile with no accessibility
treatment. Every other branch labels itself, so this one does too.

`CONTAINER_MARKER_PATHS`'s comment still claimed a bare `0::/` cgroup is
itself a container signal. Commit 75f3f48000 removed that check and rewrote
the function's docstring to say the opposite, because a host whose init
leaves PID 1 in the root cgroup reports the same value. The constant's
comment was left behind asserting the bug that commit fixed.

Model: Claude Opus 5 in T3 Code.
The mobile picker carried its own copy of the machine-kind-or-role ternary at
the choice list and again at the write rule, which is the rule the contracts
helper now owns. Call it in both places, and drop the memoization note from the
write comment since the helper states it once.
Save stayed enabled while `encodeEnvironmentIconImage` ran. Replacing an image
and saving before the encode finished wrote the previous image and closed the
dialog, and the new pick was lost with nothing saying so. Save now waits while
an image encodes in image mode, and the hint under the button reads "Preparing
image…" until the encode lands.

Opening the dialog also retires any encode still running from the last time it
was open, which could otherwise land its image in the fresh dialog.

Mobile needs none of this. Its sheet already holds `pending` across the whole
pick, so every control is disabled until the photo is ready.
Adding the Windows probe cut the shared `runProbe` timeout from 5 seconds to
1.5 seconds, which also shortened main's budget for `ioreg` and `sysctl` on
macOS. A Mac whose probe took between the two would have detected nothing and
drawn the generic server.

Each probe now passes its own timeout. The macOS probes keep main's 5 seconds,
and only the PowerShell call, the slow one boot waits on, gets 1.5 seconds.
Main replaced `Effect.catch(() => Effect.succeed(...))` with
`Effect.orElseSucceed` across the server (pingdotgg#13536), including this file's other
two helpers, and the Effect language service now reports the old form. The
container marker check added in this branch was the one call left.
Main now opens a page per environment from Settings → Environments (pingdotgg#13302)
instead of expanding the row in place. The picker still sits in the row's
expanded body, which that page shows under Connection, so the steps name the
page and the section. The section's opening line also says a machine has an
icon rather than wears one.
The line under the image button changes from the size hint to "Preparing
image…" and then to an error or back, while focus stays on the button. It
had no live region, so a screen reader announced none of it. It is now a
status region, as in the other settings dialogs.

Reported by Copilot on #5.

Claude Opus 5.5 via Claude Code
The environment icons section said an older server keeps the machine
kinds. Container is a machine kind, but it has no bare-string form, so
an older server cannot store it and the picker locks it there. The
sentence now names the original kinds.

Reported by Copilot on #5.

Claude Opus 5.5 via Claude Code
The mobile picker copied three rules from web: why the picker is locked,
whether the server takes the object form, and that picking the detected
kind clears the override. The strings and the write rule could drift
apart. They now live in @t3tools/client-runtime/environment-icon, and
both pickers use them. Web adds its session-scope check on top of the
shared lock.

The mobile logic module keeps only the list it builds for its sheet, and
the selected-id helper is inlined at its one call site. The web dialog's
save() reuses canSave instead of repeating its conditions.

Claude Opus 5.5 via Claude Code
Splits the Change icon sentence in the user guide so the list of choices
stands alone, and says Container and the richer icons stay locked on an
older server. A comment in the web image encoder now says what the catch
does instead of using a figure of speech.

Claude Opus 5.5 via Claude Code
The mobile Icon button checked only the server's capabilities. A
switched-off environment keeps its last config, and a session without the
operate scope could still open the sheet and send a write the server would
refuse. The button now locks until the environment is connected, and when
the session cannot change settings, with the same messages web shows. The
shared lock in client-runtime now takes the session's access, so web and
mobile use one function instead of web wrapping it.

The sheet also marked a colored named icon as its plain row. Tapping that
row stored the plain icon and dropped the color without a word. A row is
now selected only for a plain pick, and the note that a pick replaces an
icon set on web covers colored and Lucide icons too.

Found by an independent review of the rebased stack.

Claude Opus 5.5 via Claude Code
The Apple silicon table had the 2025 Mac Studio with M4 Max (Mac16,9) but
not the one with M3 Ultra (Mac15,14), so the fallback model lookup read it
as unknown. The container cgroup comment also said a container has no DMI,
which the probe's own comment contradicts: a container usually sees its
host's DMI, which is why the marker check runs first.

Found by an independent review of the rebased stack.

Claude Opus 5.5 via Claude Code
Two comments and a test title said every machine kind encodes to the bare
string an older server accepts. Since `container` joined the detected kinds
without joining the legacy list, that holds only for the seven legacy kinds,
and a container pick needs the object form like a role does.

Found by an independent review of the stack.

Claude Opus 5.5 via Claude Code
…where

On web, a switched-off or dropped machine keeps its cached config, so the
Change icon item stayed enabled and the dialog closed on a save the server
never got. The menu item now takes the row's connection state and locks
with the same message mobile shows.

On mobile, the round before made every environment row subscribe to its
session to compute that lock, so opening the Environments list fetched
/api/auth/session once per connected machine with no row expanded. The Icon
field and its lock now live in a child of the expanded row, so only an open
row subscribes.

Found by an independent review of the stack.

Claude Opus 5.5 via Claude Code
The lock's comment said anything beyond a plain machine kind needs the
object form. container is a machine kind without a string form, so both
pickers gate it behind this lock, and the comment now says legacy machine
kind. The mobile list's comment named only roles as gated, and now names
container as well.

Found by an independent review of the stack.

Claude Opus 5.5 via Claude Code
…oth clients

Main now separates settings writes from operating an environment (pingdotgg#9786).
Mobile still read the session by hand and checked the operate scope, so
the same session could be locked on one client and open on the other.
Both clients now ask `useEnvironmentScope` for `AuthSettingsWriteScope`,
which is what the server checks. The shared lock takes `connected` and
`canWriteSettings` booleans, so neither caller nulls the config itself, and
it waits for the grant as main's web lock does.

Claude Opus 5.5 via Claude Code
…ot probes run together

The Windows probe reports the same SMBIOS fields Linux reads, so it now
feeds `machineKindFromDmi` instead of a copy of it. That also lets Boot
Camp Macs match on their model name. The probe's JSON is used as decoded,
without a reshaping step. Label, machine, and launcher resolution run
concurrently at boot, so PowerShell no longer delays the other two. The
Linux file reads run concurrently too. The bare-cgroup reasoning is now
written once.

Claude Opus 5.5 via Claude Code
…r, shorter comments

After the emoji, monogram, and image branches return, the named variant
is all that is left, so both renderers drop their repeated kind checks
and web drops a helper that only did that. The web image encoder uses one
catch instead of two. The dialog's image input is a required nullable
like the Lucide one, its docstring lists every mode it has, and three
comments shrink to the constraint they record.

Claude Opus 5.5 via Claude Code
@amanthanvi
amanthanvi force-pushed the env-icons/06-image-mobile-detect branch from 9b25ec8 to 75fd8e1 Compare October 9, 2026 05:42
@amanthanvi

Copy link
Copy Markdown
Owner Author

Rebased onto main 563645cf6e, plus a cleanup pass

Main moved about 270 commits. The rebase had to adapt to main's split of settings writes from operating an environment (pingdotgg#9786), which now drives the icon picker lock on web. Main also added a test that the lock holds until the settings grant arrives, and layer 4's move of the lock had dropped that change, so it is back.

A cleanup pass (reuse, simplification, efficiency, altitude), then an independent review of the result, made these changes, each in the layer that owns the code:

  • Layer 1: the contract's bare-string encoding test is one named predicate, hasLegacyEnvironmentIconForm. Comments drop release history.
  • Layer 4: mobile environment emoji and monograms draw through the project icon tile, so the separate color table, thirteen theme variables, and character splitter are gone. The web dialog decides whether a server can store a pick by asking the predicate about the icon it would save. A closed picker no longer keeps settings-write hooks mounted on every Connections row.
  • Layer 5: one branch builds a colored Lucide or curated pick. The mobile Lucide glyph is memoized and keys its static nodes by position.
  • Layer 6:
    • Mobile checked the session by hand against the old operate scope. Both clients now ask useEnvironmentScope for AuthSettingsWriteScope, which is what the server checks, through one shared lock.
    • Windows detection feeds the Linux DMI classifier instead of a copy of it, and boot runs the label, machine, and launcher probes together.
    • The renderers drop kind checks TypeScript already narrowed, and the web encoder has one catch.

Every layer passes typecheck, lint, format, and its own tests. On layer 1, main's dangling-settings-link test timed out once on its first run after a branch switch, then passed three times in a row. Mobile colored glyphs now use the 500 shade in both themes, as project icons on mobile do. I have not checked that on a device.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants