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 (
+
+
+
+ );
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,