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 b6bf0c98e349c..e85a19455dcd7 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -34,7 +34,9 @@ import { toServerClock } from "@t3tools/client-runtime/clock"; import type { SnoozePresetSetting } from "@t3tools/contracts/settings"; 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 { AsyncResult, Atom } from "effect/reactivity"; import { memo, useCallback, useMemo, useState, type ComponentProps } from "react"; import { Platform, Pressable, Text as RNText, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -55,7 +57,9 @@ import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { copyThreadReference } from "../../lib/copyThreadReference"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -import { useEnvironmentScope } from "../../state/session"; +import { environmentCatalog } from "../../connection/catalog"; +import { environmentPresentations } from "../../state/presentation"; +import { useEnvironmentScopeAccess } from "../../state/session"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { pendingDraftBranchNeedsRefs, @@ -82,6 +86,9 @@ import { resolveThreadListV2Status, resolveThreadListV2ProviderDrivers, resolveThreadListV2SwipeActions, + resolveThreadListV2ConnectionIssue, + resolveThreadListV2LockedReason, + THREAD_LIST_V2_CONNECTION_ISSUE_LABEL, layoutThreadListV2RowMenuActions, type ThreadListV2Status, } from "./threadListV2"; @@ -92,6 +99,17 @@ import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; 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, + AsyncResult.getOrElse(get(environmentCatalog.stateAtom(environmentId)), () => null) + ?.lastFailure ?? null, + ), + ), +); + interface StatusLabel { // Null when the icon and timer carry the status on their own. readonly label: string | null; @@ -804,7 +822,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], @@ -1154,8 +1179,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.`; @@ -1298,7 +1331,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 ? ( ) : null} - - {props.environmentLabel} - + {props.environmentLabel ? ( + + {props.environmentLabel} + {connectionLabel ? " · " : null} + + ) : null} + {connectionLabel ? ( + // A sibling, not nested, so a long environment name truncates before the status does. + + {connectionLabel} + + ) : null} ) : ( @@ -1532,6 +1585,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { /> ) : null} + {connectionLabel ? ( + + {connectionLabel} + + ) : null} {props.hasQueuedMessages ? : null} {rowContent(() => {})}; + return ( + + { + if (event.nativeEvent.event === "locked-reason") { + Dialog.alert("Thread actions unavailable", lockedMenuTitle); + } else { + handleMenuAction(event); + } + }} + shouldOpenOnLongPress + > + {rowContent(() => {})} + + + ); return ( diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index c848e063e2bed..53bc06597a14b 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -36,6 +36,8 @@ import { buildThreadListV2Items, buildThreadListV2ListItems, getThreadListV2OrderedSection, + resolveThreadListV2ConnectionIssue, + resolveThreadListV2LockedReason, layoutThreadListV2RowMenuActions, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SwipeActions, @@ -102,6 +104,65 @@ describe("resolveThreadListV2SnoozePresets", () => { }); }); +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"); + 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", () => { + 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", "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.", + ); + 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("layoutThreadListV2RowMenuActions", () => { const actions = (...ids: string[]): MenuAction[] => ids.map((id) => ({ id, title: id })); const leafIds = (items: readonly MenuAction[]): string[] => diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 7854f9d7fdb85..3fd3d17dac5a4 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -9,6 +9,10 @@ import { threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; import type { SnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; +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 { @@ -136,6 +140,78 @@ export function resolveThreadListV2SwipeActions(input: { }; } +export type ThreadListV2ConnectionIssue = + | "offline" + | "reconnecting" + | "unreachable" + | "unsupported" + | "disconnected" + | "signed-out"; + +export const THREAD_LIST_V2_CONNECTION_ISSUE_LABEL: Record = { + offline: "Offline", + reconnecting: "Reconnecting…", + unreachable: "Can't connect", + unsupported: "Unsupported", + disconnected: "Not connected", + "signed-out": "Sign in again", +}; + +// 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, + 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": + // 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 === "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.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.`; +} + // Nested keeps iOS menus short: UIKit scrolls a taller menu, and that scroll // cancels press-drag-release selection. export function layoutThreadListV2RowMenuActions(input: { diff --git a/apps/mobile/src/state/session.ts b/apps/mobile/src/state/session.ts index 5d6a441e9b9e4..4739ffbedefc6 100644 --- a/apps/mobile/src/state/session.ts +++ b/apps/mobile/src/state/session.ts @@ -17,27 +17,46 @@ 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"; +} + export function sessionHasScope( result: AsyncResult.AsyncResult, scope: AuthEnvironmentScope, ): boolean { - const session = AsyncResult.getOrElse(result, () => null); - 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 { - // Select the grant: every revalidation emits a new result, and most consumers are list rows. +): EnvironmentScopeAccess { + // Select the access: 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), + (result) => sessionScopeAccess(result, scope), ); } +export function useEnvironmentScope( + environmentId: EnvironmentId | null, + scope: AuthEnvironmentScope, +): boolean { + return useEnvironmentScopeAccess(environmentId, scope) === "granted"; +} + export function useEnvironmentsWithScope( environments: ReadonlyArray<{ readonly environmentId: EnvironmentId }>, scope: AuthEnvironmentScope,