From 31eb9b71ac3cc177ecc80f005e285063e7717f44 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:20:59 +1100 Subject: [PATCH 1/4] fix(mobile): rows on an unreachable host say why they can't be changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thread row on a host the app can't reach dropped its swipe actions and long-press menu without any visible reason. The row now shows the host's connection issue (Offline, Reconnecting…, Can't connect, Unsupported, Not connected), and long-press opens a menu that explains why actions are unavailable and keeps Copy thread ID. Expired credentials and view-only pairings get their own explanation. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/thread-list-v2-items.tsx | 90 +++++++++++++++++-- .../src/features/threads/threadListV2.test.ts | 41 +++++++++ .../src/features/threads/threadListV2.ts | 63 +++++++++++++ apps/mobile/src/state/session.ts | 29 ++++-- 4 files changed, 210 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 6c270a0144ccf..b8283c82d88e1 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -25,9 +25,15 @@ import { formatWorkingDurationLabel, resolveThreadWorkingStartedAt, } from "@t3tools/client-runtime/state/models"; -import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts"; +import { + AuthOrchestrationOperateScope, + type EnvironmentId, + type EnvironmentMachineKind, +} from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; +import { useAtomValue } from "@effect/atom-react"; import type { MenuAction } from "@react-native-menu/menu"; +import { Atom } from "effect/reactivity"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; import { Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -42,7 +48,8 @@ import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIco import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import { useEnvironmentScope } from "../../state/session"; +import { environmentPresentations } from "../../state/presentation"; +import { useEnvironmentScopeAccess } from "../../state/session"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { resolveThreadPrBadgeAction, @@ -60,6 +67,9 @@ import { resolveThreadListV2Status, resolveThreadListV2ProviderDrivers, resolveThreadListV2SwipeActions, + resolveThreadListV2ConnectionIssue, + resolveThreadListV2LockedReason, + THREAD_LIST_V2_CONNECTION_ISSUE_LABEL, type ThreadListV2Status, } from "./threadListV2"; import { QueuedMessageIcon } from "./queued-message-icon"; @@ -68,6 +78,15 @@ import { ThreadSearchMatchExcerpt } from "./thread-search-match"; import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs"; import { pullRequestStatus } from "../pull-requests/pullRequestStatus"; +// A string, so rows re-render only when the issue changes, not on every catalog update. +const environmentConnectionIssueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => + resolveThreadListV2ConnectionIssue( + get(environmentPresentations.presentationAtom(environmentId))?.connection ?? null, + ), + ), +); + const TITLE_FAVICON_SIZE = 15; // Centers the favicon on the first line of the text-base title. const TITLE_FAVICON_OFFSET = 4; @@ -681,7 +700,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleDelete = useCallback(() => onDeleteThread(thread), [onDeleteThread, thread]); const handleRename = useCallback(() => onRenameThread(thread), [onRenameThread, thread]); - const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); + const operateAccess = useEnvironmentScopeAccess( + thread.environmentId, + AuthOrchestrationOperateScope, + ); + const canOperateThread = operateAccess === "granted"; + const connectionIssue = useAtomValue(environmentConnectionIssueAtom(thread.environmentId)); + const connectionLabel = + connectionIssue === null ? null : THREAD_LIST_V2_CONNECTION_ISSUE_LABEL[connectionIssue]; const handleRegenerateTitle = useCallback( () => onRegenerateThreadTitle(thread), [onRegenerateThreadTitle, thread], @@ -995,8 +1021,16 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { : null, [handleMenuAction, snoozePresetActions, swipeActions.secondary, thread.title], ); + const lockedMenuTitle = + operateAccess === "granted" + ? "" + : resolveThreadListV2LockedReason({ + access: operateAccess, + connectionIssue, + environmentLabel: props.environmentLabel ?? null, + }); const swipeAccessibilityHint = !canOperateThread - ? "Opens the thread" + ? `Opens the thread. ${lockedMenuTitle}` : secondaryAction === null ? `Opens the thread. Swipe left to ${primaryAction.label.toLowerCase()}.` : `Opens the thread. Swipe left for ${primaryAction.label.toLowerCase()} and snooze actions.`; @@ -1121,7 +1155,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : null} - {(status === "failed" || status === "limited") && thread.runtime?.lastError ? ( + {(status === "failed" || status === "limited") && + thread.runtime?.lastError && + // A host that dropped explains the row better than its last run's error. + connectionLabel === null ? ( {thread.runtime.lastError} - ) : props.environmentLabel ? ( + ) : props.environmentLabel || connectionLabel ? ( - {props.environmentMachine ? ( + {props.environmentLabel && props.environmentMachine ? ( {props.environmentLabel} + {props.environmentLabel && connectionLabel ? " · " : null} + {connectionLabel ? ( + + {connectionLabel} + + ) : null} ) : ( @@ -1328,6 +1378,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { /> ) : null} + {connectionLabel ? ( + + {connectionLabel} + + ) : null} {props.hasQueuedMessages ? : null} {rowContent(() => {})}; + return ( + + + {rowContent(() => {})} + + + ); return ( diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index de7400ba741ee..88c25b8759474 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -34,6 +34,8 @@ import { buildThreadListV2Items, buildThreadListV2ListItems, getThreadListV2OrderedSection, + resolveThreadListV2ConnectionIssue, + resolveThreadListV2LockedReason, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2Status, resolveThreadListV2WokeAt, @@ -76,6 +78,45 @@ describe("resolveThreadListV2SnoozeMenuSelection", () => { }); }); +describe("thread rows on hosts they cannot change", () => { + it("names the connection issue and stays quiet while connected or on a first attempt", () => { + expect(resolveThreadListV2ConnectionIssue({ phase: "connected", error: null })).toBeNull(); + expect(resolveThreadListV2ConnectionIssue({ phase: "connecting", error: null })).toBeNull(); + expect(resolveThreadListV2ConnectionIssue(null)).toBeNull(); + expect(resolveThreadListV2ConnectionIssue({ phase: "offline", error: null })).toBe("offline"); + expect(resolveThreadListV2ConnectionIssue({ phase: "reconnecting", error: null })).toBe( + "reconnecting", + ); + expect(resolveThreadListV2ConnectionIssue({ phase: "reconnecting", error: "timeout" })).toBe( + "unreachable", + ); + expect(resolveThreadListV2ConnectionIssue({ phase: "error", error: null })).toBe("unreachable"); + }); + + it("explains a missing grant, expired credentials, and connection issues differently", () => { + const reason = ( + access: "denied" | "signed-out" | "unknown", + connectionIssue: Parameters[0]["connectionIssue"], + ) => resolveThreadListV2LockedReason({ access, connectionIssue, environmentLabel: "lxso2" }); + + expect(reason("denied", null)).toBe( + "This device can view threads on lxso2 but not change them.", + ); + expect(reason("signed-out", null)).toBe( + "This device is no longer signed in to lxso2. Pair again to change threads.", + ); + expect(reason("unknown", "unsupported")).toBe( + "lxso2 and this app need compatible versions before threads can be changed.", + ); + expect(reason("unknown", "unreachable")).toBe( + "Thread actions are unavailable until lxso2 reconnects.", + ); + expect(reason("unknown", null)).toBe( + "Thread actions are unavailable until lxso2 confirms this device's access.", + ); + }); +}); + describe("resolveThreadListV2Status", () => { it("presents decoded thread waits as Waiting while preserving requests and older servers", () => { const raw = makeRawThreadShell({ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index d72ab913dec35..f3ea344d8acbc 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -8,6 +8,7 @@ import { threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; +import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models"; import { @@ -120,6 +121,68 @@ export function resolveThreadListV2SwipeActions(input: { }; } +export type ThreadListV2ConnectionIssue = + | "offline" + | "reconnecting" + | "unreachable" + | "unsupported" + | "disconnected"; + +export const THREAD_LIST_V2_CONNECTION_ISSUE_LABEL: Record = { + offline: "Offline", + reconnecting: "Reconnecting…", + unreachable: "Can't connect", + unsupported: "Unsupported", + disconnected: "Not connected", +}; + +// Cached shells keep listing a host's threads after it drops; the row says so +// instead of silently refusing swipes and menus. +export function resolveThreadListV2ConnectionIssue( + connection: Pick | null, +): ThreadListV2ConnectionIssue | null { + switch (connection?.phase) { + case undefined: + case "connected": + // Every host passes through its first attempt at launch; labelling it would + // flash on each row. A failed attempt moves on to "reconnecting". + case "connecting": + return null; + case "reconnecting": + return connection.error === null ? "reconnecting" : "unreachable"; + case "error": + return "unreachable"; + case "unsupported": + return "unsupported"; + case "offline": + return "offline"; + case "available": + return "disconnected"; + } +} + +export function resolveThreadListV2LockedReason(input: { + readonly access: "denied" | "signed-out" | "unknown"; + readonly connectionIssue: ThreadListV2ConnectionIssue | null; + readonly environmentLabel: string | null; +}): string { + const environment = input.environmentLabel ?? "this environment"; + if (input.access === "denied") { + return `This device can view threads on ${environment} but not change them.`; + } + if (input.access === "signed-out") { + return `This device is no longer signed in to ${environment}. Pair again to change threads.`; + } + if (input.connectionIssue === "unsupported") { + return `${environment} and this app need compatible versions before threads can be changed.`; + } + if (input.connectionIssue !== null) { + return `Thread actions are unavailable until ${environment} reconnects.`; + } + // Covers both a host still connecting and a session lookup that failed on a live socket. + return `Thread actions are unavailable until ${environment} confirms this device's access.`; +} + export function resolveThreadListV2SnoozeGateExpiryMs( thread: Pick< EnvironmentThreadShell, diff --git a/apps/mobile/src/state/session.ts b/apps/mobile/src/state/session.ts index ead11793f6a42..a0efa0960a5a6 100644 --- a/apps/mobile/src/state/session.ts +++ b/apps/mobile/src/state/session.ts @@ -17,24 +17,43 @@ export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRu const EMPTY_SESSION_STATE_ATOM = Atom.make(AsyncResult.initial()); +export type EnvironmentScopeAccess = "granted" | "denied" | "signed-out" | "unknown"; + +function sessionScopeAccess( + result: AsyncResult.AsyncResult, + scope: AuthEnvironmentScope, +): EnvironmentScopeAccess { + const session = Option.getOrNull(AsyncResult.value(result)); + if (result._tag === "Failure" || session === null) return "unknown"; + if (!session.authenticated) return "signed-out"; + return sessionGrantsScope(session, scope) ? "granted" : "denied"; +} + function sessionHasScope( result: AsyncResult.AsyncResult, scope: AuthEnvironmentScope, ): boolean { - const session = Option.getOrNull(AsyncResult.value(result)); - return result._tag !== "Failure" && session !== null && sessionGrantsScope(session, scope); + return sessionScopeAccess(result, scope) === "granted"; } -export function useEnvironmentScope( +/** Separates a grant the session lacks from expired credentials and a session that never loaded. */ +export function useEnvironmentScopeAccess( environmentId: EnvironmentId | null, scope: AuthEnvironmentScope, -): boolean { +): EnvironmentScopeAccess { const result = useAtomValue( environmentId === null ? EMPTY_SESSION_STATE_ATOM : environmentSession.sessionStateAtom(environmentId), ); - return sessionHasScope(result, scope); + return sessionScopeAccess(result, scope); +} + +export function useEnvironmentScope( + environmentId: EnvironmentId | null, + scope: AuthEnvironmentScope, +): boolean { + return useEnvironmentScopeAccess(environmentId, scope) === "granted"; } export function useEnvironmentsWithScope( From 643eee3aecd23185c16fcf3f90bfb39518bdcf3e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 11:26:01 +1100 Subject: [PATCH 2/4] fix(mobile): a long environment name no longer hides the connection label The connection label was nested in the environment name's single-line Text, so a long name truncated the label away. It is now a sibling that keeps its width while the name truncates. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/thread-list-v2-items.tsx | 52 ++++++++++--------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 94a28ebd52d67..4fd01c3845ff6 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -1205,30 +1205,34 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } /> ) : null} - - {props.environmentLabel} - {props.environmentLabel && connectionLabel ? " · " : null} - {connectionLabel ? ( - - {connectionLabel} - - ) : null} - + {props.environmentLabel ? ( + + {props.environmentLabel} + {connectionLabel ? " · " : null} + + ) : null} + {connectionLabel ? ( + // A sibling, not nested, so a long environment name truncates before the status does. + + {connectionLabel} + + ) : null} ) : ( From 55115f7017ddec005465c4a2167d23d7ee06328e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 01:27:43 +0000 Subject: [PATCH 3/4] fix(mobile): explain locked rows on android and invalid credentials --- .../features/threads/thread-list-v2-items.tsx | 20 +++++++++++++--- .../src/features/threads/threadListV2.test.ts | 20 ++++++++++++++++ .../src/features/threads/threadListV2.ts | 23 +++++++++++++++---- 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 6d977eaed9127..11d3fa417e20a 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -35,7 +35,7 @@ import { canSnooze } from "@t3tools/client-runtime/state/thread-settled"; import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { useAtomValue } from "@effect/atom-react"; import type { MenuAction } from "@react-native-menu/menu"; -import { Atom } from "effect/reactivity"; +import { AsyncResult, Atom } from "effect/reactivity"; import { memo, useCallback, useMemo, useState, type ComponentProps } from "react"; import { Platform, Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -52,6 +52,7 @@ import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { copyThreadReference } from "../../lib/copyThreadReference"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; +import { environmentCatalog } from "../../connection/catalog"; import { environmentPresentations } from "../../state/presentation"; import { useEnvironmentScopeAccess } from "../../state/session"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; @@ -97,6 +98,8 @@ const environmentConnectionIssueAtom = Atom.family((environmentId: EnvironmentId Atom.make((get) => resolveThreadListV2ConnectionIssue( get(environmentPresentations.presentationAtom(environmentId))?.connection ?? null, + AsyncResult.getOrElse(get(environmentCatalog.stateAtom(environmentId)), () => null) + ?.lastFailure ?? null, ), ), ); @@ -1530,8 +1533,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { { + if (event.nativeEvent.event === "locked-reason") { + Dialog.alert("Thread actions unavailable", lockedMenuTitle); + } else { + handleMenuAction(event); + } + }} shouldOpenOnLongPress > {rowContent(() => {})} diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 24d19eeb49c58..ed50ffb10ca42 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -114,6 +114,24 @@ describe("thread rows on hosts they cannot change", () => { "unreachable", ); expect(resolveThreadListV2ConnectionIssue({ phase: "error", error: null })).toBe("unreachable"); + expect( + resolveThreadListV2ConnectionIssue( + { phase: "error", error: "The environment credential is invalid." }, + { reason: "authentication", hop: "server", via: "direct" }, + ), + ).toBe("signed-out"); + expect( + resolveThreadListV2ConnectionIssue( + { phase: "error", error: "network" }, + { reason: "network", hop: "server", via: "direct" }, + ), + ).toBe("unreachable"); + expect( + resolveThreadListV2ConnectionIssue( + { phase: "error", error: "cloud sign-in" }, + { reason: "authentication", hop: "relay", via: "relay" }, + ), + ).toBe("unreachable"); }); it("explains a missing grant, expired credentials, and connection issues differently", () => { @@ -128,6 +146,8 @@ describe("thread rows on hosts they cannot change", () => { expect(reason("signed-out", null)).toBe( "This device is no longer signed in to lxso2. Pair again to change threads.", ); + expect(reason("unknown", "signed-out")).toBe(reason("signed-out", null)); + expect(reason("denied", "signed-out")).toBe(reason("signed-out", null)); expect(reason("unknown", "unsupported")).toBe( "lxso2 and this app need compatible versions before threads can be changed.", ); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 7b79cfd35cf7c..aacdf70aa6e62 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -9,7 +9,10 @@ import { threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; -import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; +import type { + ConnectionAttemptError, + EnvironmentConnectionPresentation, +} from "@t3tools/client-runtime/connection"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { resolveThreadProviderStack } from "@t3tools/client-runtime/state/models"; import { @@ -140,7 +143,8 @@ export type ThreadListV2ConnectionIssue = | "reconnecting" | "unreachable" | "unsupported" - | "disconnected"; + | "disconnected" + | "signed-out"; export const THREAD_LIST_V2_CONNECTION_ISSUE_LABEL: Record = { offline: "Offline", @@ -148,13 +152,22 @@ export const THREAD_LIST_V2_CONNECTION_ISSUE_LABEL: Record | null, + failure: Pick | null = null, ): ThreadListV2ConnectionIssue | null { + if ( + connection?.phase === "error" && + failure?.reason === "authentication" && + failure.hop === "server" && + failure.via === "direct" + ) + return "signed-out"; switch (connection?.phase) { case undefined: case "connected": @@ -181,12 +194,12 @@ export function resolveThreadListV2LockedReason(input: { readonly environmentLabel: string | null; }): string { const environment = input.environmentLabel ?? "this environment"; + if (input.access === "signed-out" || input.connectionIssue === "signed-out") { + return `This device is no longer signed in to ${environment}. Pair again to change threads.`; + } if (input.access === "denied") { return `This device can view threads on ${environment} but not change them.`; } - if (input.access === "signed-out") { - return `This device is no longer signed in to ${environment}. Pair again to change threads.`; - } if (input.connectionIssue === "unsupported") { return `${environment} and this app need compatible versions before threads can be changed.`; } From 5422b61caf872fbbeefa4620a3946da4c0279515 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 16:43:58 +0000 Subject: [PATCH 4/4] refactor(mobile): useEnvironmentScope reads through useEnvironmentScopeAccess Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/state/session.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/state/session.ts b/apps/mobile/src/state/session.ts index d8f251c3bde7b..4739ffbedefc6 100644 --- a/apps/mobile/src/state/session.ts +++ b/apps/mobile/src/state/session.ts @@ -41,6 +41,7 @@ export function useEnvironmentScopeAccess( environmentId: EnvironmentId | null, scope: AuthEnvironmentScope, ): EnvironmentScopeAccess { + // Select the access: every revalidation emits a new result, and most consumers are list rows. return useAtomValue( environmentId === null ? EMPTY_SESSION_STATE_ATOM @@ -53,13 +54,7 @@ export function useEnvironmentScope( environmentId: EnvironmentId | null, scope: AuthEnvironmentScope, ): boolean { - // Select the grant: every revalidation emits a new result, and most consumers are list rows. - return useAtomValue( - environmentId === null - ? EMPTY_SESSION_STATE_ATOM - : environmentSession.sessionStateAtom(environmentId), - (result) => sessionHasScope(result, scope), - ); + return useEnvironmentScopeAccess(environmentId, scope) === "granted"; } export function useEnvironmentsWithScope(