Skip to content

fix(app): drive Home session list, search and row gestures with explicit state machines - #61927

Merged
Hona merged 3 commits into
devfrom
home-sessions-machine
Nov 5, 2026
Merged

Hona merged 3 commits into
devfrom
home-sessions-machine

Conversation

@Hona

@Hona Hona commented Nov 5, 2026

Copy link
Copy Markdown
Member

Home's session list, its search panel and the row menu/rename each run on one explicit state machine now. Each machine has a pure transition, selectors for the UI, and a thin driver that fences results by attempt.

Bugs fixed

  • The list loads forever while the server cannot answer. The query was enabled only while connected, so a signed-out, unreachable, incompatible or stuck-connecting server stayed isPending. Home then showed placeholder rows and the search spinner forever. Now:
    • an unreachable server shows "Can't reach this server" with Try again;
    • a signed-out server shows "Signed out of this server";
    • an incompatible server shows "This server version isn't supported";
    • a connect that takes longer than 15 s ends as unreachable.
  • A failed load looked like an empty server. A 500 left isPending false with no data, so Home showed "Nothing here yet / New session". It now shows "Couldn't load sessions", the error, and Try again. A list request that hangs times out after 30 s.
  • A failed delete hid the session until a refetch. The cached index dropped the row before DELETE settled. The data layer already hides the row on remove() and shows it again on failure, so the cache now drops the row only after the server confirms the delete.
  • Placeholder rows faded over errors and server switches. Two setTimeout effects drove the placeholder rows. The skeleton and reveal are now machine phases, so an error or a cached server shows at once.
  • Tabbing out of search left the results panel open over the list until the next pointer press. Focus leaving the search control now closes the panel and keeps the text. Tabbing back reopens it.
  • A session ID found on one server stayed after you switched server or project. Opening it then opened server A's session ID on server B. A scope change now drops the match and looks the ID up again. Lookups are abortable and time out after 10 s.
  • A rename save that hung left the row stuck. The disabled input lost focus, and closeEditor() ignored Escape while saving. The input is now read-only while saving, so it keeps focus and Escape cancels the save (abort). A failed save returns to editing with the draft. A save that times out after 15 s does the same and shows a toast.
  • A row remount during a long press lost its click suppression. The row kept its suppress-click and outside-dismiss flags in local let variables, so a session that moved to another date group mid-gesture lost them. The flags now live in the machine at the list root.

State

// load-machine.ts: the phase only; the list stays in the shared ["home-sessions", conn] query cache
type HomeSessionsLoadState =
  | { status: "idle"; issued }
  | { status: "connecting"; issued; server; attempt; since; skeleton }
  | { status: "loading"; issued; server; attempt; since; skeleton; queued }
  | { status: "ready"; issued; server; reveal }
  | { status: "refreshing"; issued; server; attempt; reveal; queued }
  | { status: "unavailable"; issued; server; reason: "offline" | "auth" | "incompatible" }
  | { status: "error"; issued; server; failure: { type: "rejected"; message } | { type: "timeout" } }
  | { status: "disposed" }

// search-machine.ts
type HomeSessionSearchState =
  | (Fields & { status: "idle" })
  | (Fields & { status: "looking-up"; attempt; sessionID })
  | (Fields & { status: "found"; exact: HomeSessionRecord })
  | { status: "disposed" } // Fields = { scope, connected, issued, focused, value, highlighted }

// row-machine.ts
type HomeSessionRowState =
  | { status: "idle"; issued; suppressClick: string | undefined }
  | { status: "pressing"; issued; attempt; sessionID; x; y; menuX; menuY }
  | { status: "menu"; issued; sessionID; x; y; suppressClick; outside }
  | { status: "editing"; issued; sessionID; draft }
  | { status: "renaming"; issued; attempt; sessionID; draft }
  | { status: "disposed" }
stateDiagram-v2
  [*] --> idle
  idle --> connecting: server connecting, no cache
  idle --> loading: connected, no cache
  idle --> ready: cached and fresh, or server away with a cache
  idle --> refreshing: connected, cached but stale
  idle --> unavailable: offline / auth / incompatible
  connecting --> loading: connected (keeps placeholder rows)
  connecting --> unavailable: 15 s connect timeout
  loading --> ready: fetch.succeeded (reveal if rows showed)
  loading --> error: fetch.failed / 30 s timeout
  error --> loading: Try again, invalidation
  unavailable --> connecting: Try again (probes servers)
  ready --> refreshing: invalidated, stale on reconnect
  refreshing --> ready: settles (a failure keeps the list)
Loading
Row machine Event Next
idle touch pointerdown pressing (500 ms timer)
pressing move > 8 px / pointerup idle
pressing timer, or Android contextmenu menu { suppressClick: true }
menu close idle (suppressClick carried until the release click), focus row unless dismissed outside
any Rename editing
editing Enter (changed) renaming (abortable save, 15 s)
renaming Escape idle + abort; blur is ignored
renaming failed / timed out editing with the draft

Kept from the query

  • Cache: the list reads and writes ["home-sessions", conn] with the same options as before: no retry (retry: false), 30 s stale time, and gcTime: Infinity, which keeps it across visits. Fetches go through queryClient.fetchQuery, so a fetch the sidebar already started is joined.
  • Refetch: the list refetches on mount or retarget when stale, when the cached query is invalidated (any invalidateQueries, for example from side tabs), and when onlineManager reports online while the list is stale. A refresh asked for during a fetch runs once more after it.
  • Pointer events: pointermove checks selectLongPressMoved before it builds an event, so a move allocates nothing unless it cancels a pending long press.

Deleted

  • useQuery in the controller, the [placeholder, setPlaceholder] signal and its two timers, and the pre-request setQueryData in remove.
  • The search store { value, focused, highlighted, exact, lookingUp } and its let lookup counter.
  • The rowUI store and the four per-row let flags: longPressTimer, longPressStart, suppressClick and menuInteractedOutside.

Product calls and deviations from the design

  • Placeholder rows while connecting: connecting shows the same delayed placeholder rows and search spinner as loading, bounded by the 15 s connect timeout. This is closer to dev than a blank list, and the rows carry over into the first fetch.
  • Error over open-tab sessions: a failed first load shows the error even when open-tab sessions exist. A partial list would hide the failure.
  • Errors and reconnects: an error waits for Try again or an invalidation. When the server goes away, the error is replaced by the server's unavailable state.
  • Saves in the background: a rename save is aborted only by Escape or by its timeout. Starting another gesture lets it finish in the background, because the server may already have it.
  • Row menu trigger: the hidden menu trigger is disabled, so it takes no focus. Kobalte's DropdownMenu focuses its trigger on close unless the user interacted outside. That stole focus from the rename editor the Rename item had just focused.
  • Dropped from the design: a window-level Escape fallback while saving. The read-only input keeps focus, so the fallback is not needed.
  • Benchmark: the change is off the hot path, so it has no benchmark. Load, search and menu transitions are not per-frame work, and pointer moves allocate nothing.
  • Feature flag: none. This is a bug fix.

Visual

Before After
List request fails
Tab out of search
Escape while a rename save hangs
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" fill="none">
  <style>
    @media (prefers-reduced-motion: no-preference) {
      .sk { animation: sk 4s ease-in-out infinite; }
      .er { animation: er 4s ease-in-out infinite; }
    }
    @keyframes sk { 0%, 40% { opacity: .25 } 50%, 100% { opacity: 0 } }
    @keyframes er { 0%, 45% { opacity: 0 } 55%, 100% { opacity: 1 } }
  </style>
  <rect x=".5" y=".5" width="239" height="71" rx="6" stroke="currentColor" stroke-opacity=".2"/>
  <g class="sk" opacity="0" fill="currentColor">
    <rect x="16" y="14" width="120" height="8" rx="4"/>
    <rect x="16" y="32" width="88" height="8" rx="4"/>
    <rect x="16" y="50" width="140" height="8" rx="4"/>
  </g>
  <g class="er" fill="currentColor" font-family="sans-serif" text-anchor="middle">
    <text x="120" y="28" font-size="11">Couldn't load sessions</text>
    <rect x="88" y="38" width="64" height="20" rx="4" fill="none" stroke="currentColor" stroke-opacity=".4"/>
    <text x="120" y="52" font-size="10">Try again</text>
  </g>
</svg>

@Hona
Hona requested a review from Brendonovich as a code owner November 5, 2026 04:19
Copilot AI balanced review requested due to automatic review settings November 5, 2026 04:19

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The change looks sound. I couldn't run it in a browser here (it's a web/desktop app change and the review time was limited). The new machine unit tests pass (47/47) and tsc --noEmit on packages/app is clean.

I checked the parts most likely to regress:

  • Delete: moving the home-sessions cache drop to after DELETE resolves is safe, because createSessionMutations keeps the row hidden from remove() until the request fails.
  • Stale results: the load, search and rename drivers all fence results by attempt and abort or cancel on retarget, timeout and dispose.
  • Error and offline states: the unavailable state yields to cached or live sessions, and a failed refresh keeps the list it already shows.

Public API: unchanged. These are internal app props and two new en i18n keys.

No blocking concerns. If you can, please attach a short before/after recording of the connecting → unreachable path (15 s timeout) and of the signed-out state; the screenshots don't cover those.

@Hona
Hona merged commit c1cfbd1 into dev Nov 5, 2026
21 checks passed
@Hona
Hona deleted the home-sessions-machine branch November 5, 2026 04:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants