Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 106 additions & 18 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -82,6 +86,9 @@ import {
resolveThreadListV2Status,
resolveThreadListV2ProviderDrivers,
resolveThreadListV2SwipeActions,
resolveThreadListV2ConnectionIssue,
resolveThreadListV2LockedReason,
THREAD_LIST_V2_CONNECTION_ISSUE_LABEL,
layoutThreadListV2RowMenuActions,
type ThreadListV2Status,
} from "./threadListV2";
Expand All @@ -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;
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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.`;
Expand Down Expand Up @@ -1298,7 +1331,10 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
</View>
) : null}
<View className="mt-1 flex-row items-center gap-2" style={{ paddingLeft: titleTextInset }}>
{(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 ? (
<Text
className={cn(
"flex-1 text-xs",
Expand All @@ -1312,9 +1348,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
>
{thread.runtime.lastError}
</Text>
) : props.environmentLabel ? (
) : props.environmentLabel || connectionLabel ? (
<View className="min-w-0 flex-1 flex-row items-center gap-1">
{props.environmentMachine ? (
{props.environmentLabel && props.environmentMachine ? (
<EnvironmentMachineSymbol
kind={props.environmentMachine}
size={machineIconSize}
Expand All @@ -1325,17 +1361,34 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
}
/>
) : null}
<Text
className={cn(
"shrink text-xs",
selected
? selectedThreadRowColors.mutedForegroundClassName
: rowAppearance.tertiaryForegroundClassName,
)}
numberOfLines={1}
>
{props.environmentLabel}
</Text>
{props.environmentLabel ? (
<Text
className={cn(
"shrink text-xs",
selected
? selectedThreadRowColors.mutedForegroundClassName
: rowAppearance.tertiaryForegroundClassName,
)}
numberOfLines={1}
>
{props.environmentLabel}
{connectionLabel ? " · " : null}
</Text>
) : null}
{connectionLabel ? (
// A sibling, not nested, so a long environment name truncates before the status does.
<Text
className={cn(
"max-w-full shrink-0 text-xs",
selected
? selectedThreadRowColors.mutedForegroundClassName
: "text-warning-foreground",
)}
numberOfLines={1}
>
{connectionLabel}
</Text>
) : null}
</View>
) : (
<View className="flex-1" />
Expand Down Expand Up @@ -1532,6 +1585,19 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
/>
) : null}
</View>
{connectionLabel ? (
<Text
className={cn(
"text-xs",
selected
? selectedThreadRowColors.mutedForegroundClassName
: "text-warning-foreground",
)}
numberOfLines={1}
>
{connectionLabel}
</Text>
) : null}
{props.hasQueuedMessages ? <QueuedMessageIcon selected={selected} /> : null}
<Text
className={cn(
Expand All @@ -1553,7 +1619,29 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
);

if (!canOperateThread)
return <View style={rowAppearance.swipeContainerStyle}>{rowContent(() => {})}</View>;
return (
<View style={rowAppearance.swipeContainerStyle}>
<Menu
title={lockedMenuTitle}
actions={[
...(Platform.OS === "android"
? [{ id: "locked-reason", title: "Why are actions unavailable?" }]
: []),
{ id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" },
]}
onPressAction={(event) => {
if (event.nativeEvent.event === "locked-reason") {
Dialog.alert("Thread actions unavailable", lockedMenuTitle);
} else {
handleMenuAction(event);
}
}}
shouldOpenOnLongPress
>
{rowContent(() => {})}
</Menu>
</View>
);

return (
<View collapsable={false}>
Expand Down
61 changes: 61 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ import {
buildThreadListV2Items,
buildThreadListV2ListItems,
getThreadListV2OrderedSection,
resolveThreadListV2ConnectionIssue,
resolveThreadListV2LockedReason,
layoutThreadListV2RowMenuActions,
resolveThreadListV2SnoozeMenuSelection,
resolveThreadListV2SwipeActions,
Expand Down Expand Up @@ -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<typeof resolveThreadListV2LockedReason>[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[] =>
Expand Down
76 changes: 76 additions & 0 deletions apps/mobile/src/features/threads/threadListV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<ThreadListV2ConnectionIssue, string> = {
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<EnvironmentConnectionPresentation, "phase" | "error"> | null,
failure: Pick<ConnectionAttemptError, "reason" | "hop" | "via"> | 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: {
Expand Down
Loading
Loading