diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index 4ea43792c97b..41dfce2e1351 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -28,6 +28,8 @@ import { ReviewSheet } from "./features/review/ReviewSheet";
// T3-CUSTOM(expbkt3): native plan review screens.
import { PlanReviewSheet } from "./features/planreview/PlanReviewSheet";
import { PlanReviewCommentSheet } from "./features/planreview/PlanReviewCommentSheet";
+// T3-CUSTOM(expbkt3): thread member tagging.
+import { ThreadMembersSheet } from "./features/members/ThreadMembersSheet";
import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen";
import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet";
import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
@@ -524,6 +526,19 @@ export const RootStack = createNativeStackNavigator({
headerShown: false,
},
}),
+ ThreadMembers: createNativeStackScreen({
+ screen: ThreadMembersSheet,
+ linking: `${THREAD_LINKING_PREFIX}/members`,
+ options: {
+ // Same Android constraint as the other keyboard-driven sheets.
+ ...(Platform.OS === "android"
+ ? { presentation: "fullScreenModal" as const }
+ : FORM_SHEET_PRESENTATION_OPTIONS),
+ sheetAllowedDetents: Platform.OS === "android" ? undefined : [0.6, 0.95],
+ sheetGrabberVisible: Platform.OS !== "android",
+ headerShown: false,
+ },
+ }),
// T3-CUSTOM(expbkt3): END native plan review
ThreadFiles: createNativeStackScreen({
screen: ThreadFilesTreeScreen,
diff --git a/apps/mobile/src/features/members/ThreadMembersSheet.tsx b/apps/mobile/src/features/members/ThreadMembersSheet.tsx
new file mode 100644
index 000000000000..a52554dc7180
--- /dev/null
+++ b/apps/mobile/src/features/members/ThreadMembersSheet.tsx
@@ -0,0 +1,207 @@
+// T3-CUSTOM(expbkt3): tag users on a thread, from the phone.
+//
+// The commands (`addMember`, `removeMember`, `transferOwnership`) already live in
+// client-runtime and are what web's ThreadMembersControl calls, so this is the
+// directory plus a list. Ordering and filtering are in threadMembers.ts, kept
+// pure so they are testable without a renderer.
+import { useNavigation, type StaticScreenProps } from "@react-navigation/native";
+import type { EnvironmentId, OrchestrationUser, ThreadId, UserId } from "@t3tools/contracts";
+import { useCallback, useMemo, useState } from "react";
+import { ActivityIndicator, Pressable, ScrollView, View } from "react-native";
+import { useSafeAreaInsets } from "react-native-safe-area-context";
+
+import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText";
+import { SymbolView } from "../../components/AppSymbol";
+import { useThemeColor } from "../../lib/useThemeColor";
+import { useEnvironmentQuery } from "../../state/query";
+import { environmentSession } from "../../state/session";
+import { threadEnvironment } from "../../state/threads";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { useThreadShell } from "../../state/entities";
+import {
+ buildThreadMemberEntries,
+ canRemoveThreadMember,
+ filterThreadMemberEntries,
+ threadMemberInitial,
+ threadMemberLabel,
+ type ThreadMemberEntry,
+} from "./threadMembers";
+
+type ThreadMembersSheetProps = StaticScreenProps<{
+ readonly environmentId: EnvironmentId;
+ readonly threadId: ThreadId;
+}>;
+
+function MemberRow(props: {
+ readonly entry: ThreadMemberEntry;
+ readonly isBusy: boolean;
+ readonly onToggle: (entry: ThreadMemberEntry) => void;
+ readonly onTransferOwnership: (entry: ThreadMemberEntry) => void;
+}) {
+ const { entry } = props;
+ const iconTint = String(useThemeColor("--color-icon"));
+ const handleToggle = useCallback(() => props.onToggle(entry), [entry, props]);
+ const handleTransfer = useCallback(() => props.onTransferOwnership(entry), [entry, props]);
+
+ return (
+
+
+
+ {threadMemberInitial(entry.user)}
+
+
+
+
+ {threadMemberLabel(entry.user)}
+
+ {entry.isOwner ? (
+ Owner
+ ) : entry.user.email === null ? null : (
+
+ {entry.user.email}
+
+ )}
+
+
+ {props.isBusy ? (
+
+ ) : entry.isOwner ? null : (
+
+ {entry.isMember ? (
+
+ Make owner
+
+ ) : null}
+
+
+
+
+ )}
+
+ );
+}
+
+export function ThreadMembersSheet(props: ThreadMembersSheetProps) {
+ const navigation = useNavigation();
+ const insets = useSafeAreaInsets();
+ const { environmentId, threadId } = props.route.params;
+ const [query, setQuery] = useState("");
+ const [pendingUserIds, setPendingUserIds] = useState>(() => new Set());
+ const [error, setError] = useState(null);
+
+ const thread = useThreadShell({ environmentId, threadId });
+ // The org directory: UserIds that can own or be tagged on a thread.
+ const directoryQuery = useEnvironmentQuery(
+ environmentSession.orchestrationUsersAtom(environmentId),
+ );
+ const addMember = useAtomCommand(threadEnvironment.addMember, "thread add member");
+ const removeMember = useAtomCommand(threadEnvironment.removeMember, "thread remove member");
+ const transferOwnership = useAtomCommand(
+ threadEnvironment.transferOwnership,
+ "thread transfer ownership",
+ );
+
+ const users: ReadonlyArray = directoryQuery.data?.users ?? [];
+ const entries = useMemo(
+ () =>
+ filterThreadMemberEntries(
+ buildThreadMemberEntries({
+ users,
+ ownerUserId: thread?.ownerUserId ?? null,
+ memberUserIds: thread?.memberUserIds ?? [],
+ }),
+ query,
+ ),
+ [query, thread?.memberUserIds, thread?.ownerUserId, users],
+ );
+
+ const withPending = useCallback(
+ (userId: UserId, run: () => Promise<{ readonly _tag: string }>) => {
+ setPendingUserIds((current) => new Set(current).add(userId));
+ setError(null);
+ void run()
+ .then((result) => {
+ if (result._tag === "Failure") setError("That change could not be saved. Try again.");
+ })
+ .finally(() => {
+ setPendingUserIds((current) => {
+ const next = new Set(current);
+ next.delete(userId);
+ return next;
+ });
+ });
+ },
+ [],
+ );
+
+ const handleToggle = useCallback(
+ (entry: ThreadMemberEntry) => {
+ const userId = entry.user.id;
+ withPending(userId, () =>
+ canRemoveThreadMember(entry)
+ ? removeMember({ environmentId, input: { threadId, userId } })
+ : addMember({ environmentId, input: { threadId, userId } }),
+ );
+ },
+ [addMember, environmentId, removeMember, threadId, withPending],
+ );
+
+ const handleTransferOwnership = useCallback(
+ (entry: ThreadMemberEntry) => {
+ const userId = entry.user.id;
+ withPending(userId, () => transferOwnership({ environmentId, input: { threadId, userId } }));
+ },
+ [environmentId, threadId, transferOwnership, withPending],
+ );
+
+ return (
+
+
+ navigation.goBack()}>
+ Done
+
+ People
+
+
+
+
+
+ {error === null ? null : {error}}
+
+
+ {directoryQuery.isPending && users.length === 0 ? (
+
+
+
+ ) : entries.length === 0 ? (
+
+ {users.length === 0
+ ? "This environment has no user directory."
+ : "Nobody matches that search."}
+
+ ) : (
+ entries.map((entry) => (
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/members/threadMembers.test.ts b/apps/mobile/src/features/members/threadMembers.test.ts
new file mode 100644
index 000000000000..8d984e000678
--- /dev/null
+++ b/apps/mobile/src/features/members/threadMembers.test.ts
@@ -0,0 +1,114 @@
+// T3-CUSTOM(expbkt3): fork-owned coverage for thread member tagging.
+import { describe, expect, it } from "@effect/vitest";
+import type { OrchestrationUser, UserId } from "@t3tools/contracts";
+
+import {
+ buildThreadMemberEntries,
+ canRemoveThreadMember,
+ filterThreadMemberEntries,
+ threadMemberInitial,
+ threadMemberLabel,
+} from "./threadMembers";
+
+const user = (id: string, overrides: Partial = {}): OrchestrationUser =>
+ ({
+ id,
+ name: null,
+ email: null,
+ imageUrl: null,
+ isAdmin: false,
+ ...overrides,
+ }) as OrchestrationUser;
+
+describe("threadMemberLabel", () => {
+ it("prefers a display name, then an email, then the raw id", () => {
+ expect(threadMemberLabel(user("u1", { name: "Tushar" }))).toBe("Tushar");
+ expect(threadMemberLabel(user("u2", { email: "a@b.com" }))).toBe("a@b.com");
+ expect(threadMemberLabel(user("u3"))).toBe("u3");
+ });
+});
+
+describe("threadMemberInitial", () => {
+ it("uses the first letter of the label", () => {
+ expect(threadMemberInitial(user("u1", { name: "tushar" }))).toBe("T");
+ });
+
+ it("falls back rather than rendering an empty circle", () => {
+ expect(threadMemberInitial(user("", { name: " " }))).toBe("?");
+ });
+});
+
+describe("buildThreadMemberEntries", () => {
+ const users = [
+ user("zoe", { name: "Zoe" }),
+ user("owner", { name: "Owner" }),
+ user("amy", { name: "Amy" }),
+ user("member", { name: "Member" }),
+ ];
+
+ it("puts the owner first, then members, then everyone else", () => {
+ const entries = buildThreadMemberEntries({
+ users,
+ ownerUserId: "owner" as UserId,
+ memberUserIds: ["member" as UserId],
+ });
+ expect(entries.map((entry) => entry.user.id)).toEqual(["owner", "member", "amy", "zoe"]);
+ });
+
+ it("marks ownership and membership", () => {
+ const entries = buildThreadMemberEntries({
+ users,
+ ownerUserId: "owner" as UserId,
+ memberUserIds: ["member" as UserId],
+ });
+ expect(entries[0]).toMatchObject({ isOwner: true });
+ expect(entries[1]).toMatchObject({ isOwner: false, isMember: true });
+ expect(entries[2]).toMatchObject({ isOwner: false, isMember: false });
+ });
+
+ it("sorts alphabetically when nobody is tagged", () => {
+ const entries = buildThreadMemberEntries({ users, ownerUserId: null, memberUserIds: [] });
+ expect(entries.map((entry) => entry.user.id)).toEqual(["amy", "member", "owner", "zoe"]);
+ });
+});
+
+describe("filterThreadMemberEntries", () => {
+ const entries = buildThreadMemberEntries({
+ users: [
+ user("a", { name: "Tushar Bhardwaj", email: "tushar@beknown.work" }),
+ user("b", { name: "Someone Else", email: "else@example.com" }),
+ ],
+ ownerUserId: null,
+ memberUserIds: [],
+ });
+
+ it("keeps everyone for an empty query", () => {
+ expect(filterThreadMemberEntries(entries, " ")).toHaveLength(2);
+ });
+
+ it("matches a partial name, case-insensitively", () => {
+ expect(filterThreadMemberEntries(entries, "bhard").map((e) => e.user.id)).toEqual(["a"]);
+ });
+
+ it("matches an email prefix", () => {
+ expect(filterThreadMemberEntries(entries, "else@").map((e) => e.user.id)).toEqual(["b"]);
+ });
+
+ it("returns nothing when nobody matches", () => {
+ expect(filterThreadMemberEntries(entries, "zzz")).toEqual([]);
+ });
+});
+
+describe("canRemoveThreadMember", () => {
+ it("refuses to remove the owner", () => {
+ expect(canRemoveThreadMember({ user: user("o"), isOwner: true, isMember: true })).toBe(false);
+ });
+
+ it("allows removing a plain member", () => {
+ expect(canRemoveThreadMember({ user: user("m"), isOwner: false, isMember: true })).toBe(true);
+ });
+
+ it("has nothing to remove for a non-member", () => {
+ expect(canRemoveThreadMember({ user: user("x"), isOwner: false, isMember: false })).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/features/members/threadMembers.ts b/apps/mobile/src/features/members/threadMembers.ts
new file mode 100644
index 000000000000..d6e5f0600b10
--- /dev/null
+++ b/apps/mobile/src/features/members/threadMembers.ts
@@ -0,0 +1,74 @@
+// T3-CUSTOM(expbkt3): pure logic for tagging users on a thread.
+//
+// Free of react-native and of any atom, so it is unit testable — importing the
+// mobile state layer pulls in react-native, which the test bundler cannot parse.
+import type { OrchestrationUser, UserId } from "@t3tools/contracts";
+
+export interface ThreadMemberEntry {
+ readonly user: OrchestrationUser;
+ readonly isOwner: boolean;
+ readonly isMember: boolean;
+}
+
+/** A readable label for a user, however sparse their directory record is. */
+export function threadMemberLabel(user: OrchestrationUser): string {
+ return user.name ?? user.email ?? user.id;
+}
+
+/** The initial for the avatar circle. */
+export function threadMemberInitial(user: OrchestrationUser): string {
+ return threadMemberLabel(user).trim().slice(0, 1).toUpperCase() || "?";
+}
+
+/**
+ * The directory, ordered for a phone: people already on the thread first (owner
+ * at the top), then everyone else alphabetically. Scrolling to find who is
+ * already tagged is the common mistake this avoids.
+ */
+export function buildThreadMemberEntries(input: {
+ readonly users: ReadonlyArray;
+ readonly ownerUserId: UserId | null;
+ readonly memberUserIds: ReadonlyArray;
+}): ReadonlyArray {
+ const members = new Set(input.memberUserIds);
+ const entries = input.users.map((user) => ({
+ user,
+ isOwner: input.ownerUserId !== null && user.id === input.ownerUserId,
+ isMember: members.has(user.id),
+ }));
+
+ return entries.sort((left, right) => {
+ if (left.isOwner !== right.isOwner) return left.isOwner ? -1 : 1;
+ if (left.isMember !== right.isMember) return left.isMember ? -1 : 1;
+ return threadMemberLabel(left.user).localeCompare(threadMemberLabel(right.user));
+ });
+}
+
+/**
+ * Filters the directory by a query over name and email.
+ *
+ * An empty query keeps everyone; matching is case-insensitive and substring, so
+ * a partial surname or an email prefix both work.
+ */
+export function filterThreadMemberEntries(
+ entries: ReadonlyArray,
+ query: string,
+): ReadonlyArray {
+ const needle = query.trim().toLowerCase();
+ if (needle.length === 0) return entries;
+ return entries.filter((entry) => {
+ const name = entry.user.name?.toLowerCase() ?? "";
+ const email = entry.user.email?.toLowerCase() ?? "";
+ return name.includes(needle) || email.includes(needle);
+ });
+}
+
+/**
+ * Whether removing this member is allowed.
+ *
+ * The owner cannot be removed as a member — ownership transfer is the operation
+ * for that, and offering a remove that always fails is worse than not offering it.
+ */
+export function canRemoveThreadMember(entry: ThreadMemberEntry): boolean {
+ return entry.isMember && !entry.isOwner;
+}
diff --git a/apps/mobile/src/features/phasesidebar/PhaseSidebarFilterSheet.tsx b/apps/mobile/src/features/phasesidebar/PhaseSidebarFilterSheet.tsx
new file mode 100644
index 000000000000..d5cf8ca8531c
--- /dev/null
+++ b/apps/mobile/src/features/phasesidebar/PhaseSidebarFilterSheet.tsx
@@ -0,0 +1,172 @@
+// T3-CUSTOM(expbkt3): the phase sidebar's filter and sort controls.
+//
+// Which facets exist, what they match and how they sanitize are all decided in
+// client-runtime; this is a sheet of toggles over that. Filters live in the
+// caller's state rather than here so the list and this sheet cannot disagree.
+import {
+ buildPhaseSidebarRepositoryOptions,
+ EMPTY_PHASE_SIDEBAR_FILTERS,
+ PHASE_SIDEBAR_PHASES,
+ PHASE_SIDEBAR_SORT_DIRECTION_LABELS,
+ type PhaseSidebarFilters,
+ type PhaseSidebarRow,
+ type PhaseSidebarSortPreferences,
+} from "@t3tools/client-runtime/state/phase-sidebar";
+import { phaseSidebarFiltersActive } from "@t3tools/client-runtime/state/phase-sidebar-tree";
+import type { EnvironmentProject } from "@t3tools/client-runtime/state/models";
+import { useMemo } from "react";
+import { Pressable, ScrollView, View } from "react-native";
+
+import { AppText as Text } from "../../components/AppText";
+import { cn } from "../../lib/cn";
+import { phaseSidebarSectionToneClassName } from "./phaseSidebarRowTone";
+
+function Chip(props: {
+ readonly label: string;
+ readonly active: boolean;
+ readonly toneClassName?: string;
+ readonly onPress: () => void;
+}) {
+ return (
+
+
+ {props.label}
+
+
+ );
+}
+
+function Section(props: { readonly title: string; readonly children: React.ReactNode }) {
+ return (
+
+
+ {props.title}
+
+ {props.children}
+
+ );
+}
+
+export function PhaseSidebarFilterSheet(props: {
+ readonly rows: ReadonlyArray;
+ readonly projects: ReadonlyArray;
+ readonly filters: PhaseSidebarFilters;
+ readonly sort: PhaseSidebarSortPreferences;
+ readonly onChangeFilters: (filters: PhaseSidebarFilters) => void;
+ readonly onChangeSort: (sort: PhaseSidebarSortPreferences) => void;
+}) {
+ const repositories = useMemo(
+ () => buildPhaseSidebarRepositoryOptions(props.projects),
+ [props.projects],
+ );
+ // Only offer providers actually present, so the sheet does not list drivers
+ // this operator never uses.
+ const providers = useMemo(() => {
+ const seen = new Map();
+ for (const row of props.rows) seen.set(row.providerKind, row.providerName);
+ return [...seen.entries()].sort((left, right) => left[1].localeCompare(right[1]));
+ }, [props.rows]);
+
+ const toggle = (list: ReadonlyArray, value: T): ReadonlyArray =>
+ list.includes(value) ? list.filter((entry) => entry !== value) : [...list, value];
+
+ return (
+
+
+ Filter
+ {phaseSidebarFiltersActive(props.filters) ? (
+ props.onChangeFilters(EMPTY_PHASE_SIDEBAR_FILTERS)}>
+ Clear all
+
+ ) : null}
+
+
+
+ {PHASE_SIDEBAR_PHASES.map((phase) => (
+
+ props.onChangeFilters({
+ ...props.filters,
+ phaseIds: toggle(props.filters.phaseIds, phase.id),
+ })
+ }
+ toneClassName={phaseSidebarSectionToneClassName(phase.id)}
+ />
+ ))}
+
+
+ {repositories.length === 0 ? null : (
+
+ {repositories.map((repository) => (
+
+ props.onChangeFilters({
+ ...props.filters,
+ repositoryKeys: toggle(props.filters.repositoryKeys, repository.key),
+ })
+ }
+ />
+ ))}
+
+ )}
+
+ {providers.length === 0 ? null : (
+
+ {providers.map(([kind, name]) => (
+
+ props.onChangeFilters({
+ ...props.filters,
+ providerKinds: toggle(props.filters.providerKinds, kind),
+ })
+ }
+ />
+ ))}
+
+ )}
+
+
+
+ props.onChangeFilters({ ...props.filters, ownedByMe: !props.filters.ownedByMe })
+ }
+ />
+
+
+
+
+ props.onChangeSort({ ...props.sort, priorityFirst: !props.sort.priorityFirst })
+ }
+ />
+ {(["newest_first", "oldest_first"] as const).map((direction) => (
+ props.onChangeSort({ ...props.sort, direction })}
+ />
+ ))}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/phasesidebar/PhaseSidebarList.tsx b/apps/mobile/src/features/phasesidebar/PhaseSidebarList.tsx
new file mode 100644
index 000000000000..717d15f2784c
--- /dev/null
+++ b/apps/mobile/src/features/phasesidebar/PhaseSidebarList.tsx
@@ -0,0 +1,239 @@
+// T3-CUSTOM(expbkt3): the mobile phase-grouped sidebar list.
+//
+// Grouping, nesting, filtering and sorting are all decided by client-runtime, so
+// this component owns exactly two things: expansion state, and turning the
+// resulting tree into a flat list a FlatList can render efficiently. Rendering
+// the tree as one flat array matters on a phone — a nested render tree of
+// hundreds of rows drops frames on scroll.
+import {
+ buildPhaseSidebarTreeGroups,
+ flattenPhaseSidebarTree,
+ type PhaseSidebarTreeNode,
+} from "@t3tools/client-runtime/state/phase-sidebar-tree";
+import {
+ comparePhaseSidebarRows,
+ DEFAULT_PHASE_SIDEBAR_SORT,
+ EMPTY_PHASE_SIDEBAR_FILTERS,
+ resolvePhaseSidebarWorktreeView,
+ type PhaseSidebarFilters,
+ type PhaseSidebarRow,
+ type PhaseSidebarSortPreferences,
+} from "@t3tools/client-runtime/state/phase-sidebar";
+import {
+ DEFAULT_SIDEBAR_THREAD_SORT_ORDER,
+ type SidebarThreadSortOrder,
+ type UserId,
+} from "@t3tools/contracts";
+import type { MenuAction } from "@react-native-menu/menu";
+import { useCallback, useMemo, useState } from "react";
+import { Gesture, GestureDetector } from "react-native-gesture-handler";
+import { runOnJS } from "react-native-reanimated";
+import { FlatList, View } from "react-native";
+
+import { AppText as Text } from "../../components/AppText";
+import { cn } from "../../lib/cn";
+import { PhaseSidebarRowView } from "./PhaseSidebarRowView";
+import { phaseSidebarSectionToneClassName } from "./phaseSidebarRowTone";
+import { buildPhaseSidebarRowActions } from "./usePhaseSidebarRowActions";
+import { usePhaseSidebarDrag } from "./usePhaseSidebarDrag";
+
+/** A flattened list entry: either a lifecycle header or a thread row. */
+type PhaseSidebarListItem =
+ | {
+ readonly kind: "section";
+ readonly key: string;
+ readonly phaseId: PhaseSidebarRow["phaseId"];
+ readonly label: string;
+ readonly helperText: string;
+ readonly count: number;
+ }
+ | {
+ readonly kind: "row";
+ readonly key: string;
+ readonly node: PhaseSidebarTreeNode;
+ };
+
+export interface PhaseSidebarListProps {
+ readonly rows: ReadonlyArray;
+ readonly viewerUserId: UserId | null;
+ readonly activeThreadKey: string | null;
+ readonly filters?: PhaseSidebarFilters;
+ readonly sort?: PhaseSidebarSortPreferences;
+ readonly sortOrder?: SidebarThreadSortOrder;
+ readonly onSelectRow: (row: PhaseSidebarRow) => void;
+ /** Fired with the chosen action id from the row's long-press menu. */
+ readonly onRowAction: (row: PhaseSidebarRow, actionId: string) => void;
+ /** Re-parent a thread. Null parent means 'make this a root thread'. */
+ readonly onReparentRow?: (subject: PhaseSidebarRow, parent: PhaseSidebarRow | null) => void;
+ /** Reorder a pinned thread to sit before another. */
+ readonly onReorderRow?: (subject: PhaseSidebarRow, before: PhaseSidebarRow) => void;
+ readonly ListHeaderComponent?: React.ComponentProps["ListHeaderComponent"];
+}
+
+export function PhaseSidebarList(props: PhaseSidebarListProps) {
+ const filters = props.filters ?? EMPTY_PHASE_SIDEBAR_FILTERS;
+ const sort = props.sort ?? DEFAULT_PHASE_SIDEBAR_SORT;
+ const sortOrder = props.sortOrder ?? DEFAULT_SIDEBAR_THREAD_SORT_ORDER;
+ const [collapsedKeys, setCollapsedKeys] = useState>(() => new Set());
+
+ const worktreeView = useMemo(
+ // Resolved across the whole set, not per row: codenames disambiguate against
+ // each other and occupancy is a count.
+ () => resolvePhaseSidebarWorktreeView(props.rows.map((row) => row.thread)),
+ [props.rows],
+ );
+
+ const { groups, forcedExpansionKeys } = useMemo(
+ () =>
+ buildPhaseSidebarTreeGroups({
+ rows: props.rows,
+ filters,
+ compareSiblings: (left, right) => comparePhaseSidebarRows(left, right, sortOrder, sort),
+ }),
+ [filters, props.rows, sort, sortOrder],
+ );
+
+ // Parents default to open, so a session tree is visible without hunting. A
+ // filter can force a parent open; that never touches the user's own state.
+ const isExpanded = useCallback(
+ (key: string) => forcedExpansionKeys.has(key) || !collapsedKeys.has(key),
+ [collapsedKeys, forcedExpansionKeys],
+ );
+
+ const items = useMemo>(() => {
+ const flat: PhaseSidebarListItem[] = [];
+ for (const group of groups) {
+ if (group.nodes.length === 0) continue;
+ flat.push({
+ kind: "section",
+ key: `section:${group.id}`,
+ phaseId: group.id,
+ label: group.label,
+ helperText: group.helperText,
+ count: group.nodes.length,
+ });
+ for (const node of flattenPhaseSidebarTree(group.nodes, isExpanded)) {
+ flat.push({ kind: "row", key: node.key, node });
+ }
+ }
+ return flat;
+ }, [groups, isExpanded]);
+
+ const nowIso = useMemo(() => new Date().toISOString(), [props.rows]);
+ const rowActionsFor = useCallback(
+ (row: PhaseSidebarRow): MenuAction[] =>
+ buildPhaseSidebarRowActions({ row, now: nowIso }).map((action) => ({
+ id: action.id,
+ title: action.title,
+ image: action.image,
+ attributes: action.destructive === true ? { destructive: true } : undefined,
+ })),
+ [nowIso],
+ );
+
+ const noopReparent = useCallback(() => {}, []);
+ const dragEnabled = props.onReparentRow !== undefined || props.onReorderRow !== undefined;
+ const dragController = usePhaseSidebarDrag({
+ rows: props.rows,
+ rowKeyFor: (row) => `${row.thread.environmentId}:${row.thread.id}`,
+ onReparent: props.onReparentRow ?? noopReparent,
+ onReorder: props.onReorderRow ?? noopReparent,
+ });
+
+ const handleRowGeometry = useCallback(
+ (key: string, y: number, height: number, depth: number) => {
+ dragController.registerGeometry(key, { y, height, depth });
+ },
+ [dragController],
+ );
+
+ // A dedicated grab handle rather than a long-press: long-press already opens
+ // the row's context menu, and a pan that activates anywhere on the row fights
+ // the list's scroll. The handle makes the intent unambiguous.
+ const dragHandleFor = useCallback(
+ (rowKey: string) => {
+ const gesture = Gesture.Pan()
+ .activateAfterLongPress(0)
+ .onStart(() => {
+ runOnJS(dragController.beginDrag)(rowKey);
+ })
+ .onUpdate((event) => {
+ runOnJS(dragController.updateDrag)(event.absoluteY);
+ })
+ .onEnd(() => {
+ runOnJS(dragController.endDrag)();
+ })
+ .onFinalize(() => {
+ runOnJS(dragController.cancelDrag)();
+ });
+ return (
+
+
+ ≡
+
+
+ );
+ },
+ [dragController],
+ );
+
+ const handleToggleExpanded = useCallback((row: PhaseSidebarRow) => {
+ setCollapsedKeys((current) => {
+ const key = `${row.thread.environmentId}:${row.thread.id}`;
+ const next = new Set(current);
+ if (next.has(key)) next.delete(key);
+ else next.add(key);
+ return next;
+ });
+ }, []);
+
+ const list = (
+ item.key}
+ renderItem={({ item }) =>
+ item.kind === "section" ? (
+
+
+ {item.label}
+
+
+ {item.helperText}
+
+ {item.count}
+
+ ) : (
+
+ )
+ }
+ windowSize={11}
+ />
+ );
+
+ return list;
+}
diff --git a/apps/mobile/src/features/phasesidebar/PhaseSidebarRateLimits.tsx b/apps/mobile/src/features/phasesidebar/PhaseSidebarRateLimits.tsx
new file mode 100644
index 000000000000..86cdba7ee841
--- /dev/null
+++ b/apps/mobile/src/features/phasesidebar/PhaseSidebarRateLimits.tsx
@@ -0,0 +1,108 @@
+// T3-CUSTOM(expbkt3): Claude and Codex quota at a glance, in the sidebar header.
+//
+// The mobile counterpart of web's SidebarProviderRateLimits. All of the
+// arithmetic — which window is the headline, what tone it deserves, how to word
+// the reset — is already shared in client-runtime, so this is layout only.
+//
+// Mobile already has a full Usage screen (Settings → Usage) with per-day charts.
+// This deliberately does not duplicate it: it answers "how much have I got left"
+// in one glance and links through for the detail.
+import { useAtomValue } from "@effect/atom-react";
+import {
+ buildProviderRateLimitRows,
+ providerRateLimitTone,
+ type ProviderRateLimitRowView,
+ type ProviderRateLimitTone,
+} from "@t3tools/client-runtime/state/provider-rate-limits";
+import type { EnvironmentId, ProviderRateLimitsStreamSnapshot } from "@t3tools/contracts";
+import { useMemo } from "react";
+import { Pressable, View } from "react-native";
+
+import { AppText as Text } from "../../components/AppText";
+import { cn } from "../../lib/cn";
+import { useEnvironmentQuery } from "../../state/query";
+import { serverEnvironment } from "../../state/server";
+
+function toneBarClassName(tone: ProviderRateLimitTone): string {
+ switch (tone) {
+ case "danger":
+ return "bg-rose-500";
+ case "warning":
+ return "bg-amber-500";
+ case "healthy":
+ return "bg-emerald-500";
+ case "unknown":
+ return "bg-muted-foreground/40";
+ }
+}
+
+function RateLimitBar(props: { readonly row: ProviderRateLimitRowView }) {
+ const { row } = props;
+ // A provider that reports no weekly quota has nothing to draw; showing an
+ // empty bar would read as "you are out".
+ if (row.remainingPercent === null) return null;
+ const tone = providerRateLimitTone(row.remainingPercent);
+ const clamped = Math.max(0, Math.min(100, row.remainingPercent));
+
+ return (
+
+
+ {row.displayName}
+
+
+
+
+
+ {Math.round(clamped)}%
+
+
+ );
+}
+
+export function PhaseSidebarRateLimits(props: {
+ readonly environmentId: EnvironmentId | null;
+ readonly onPress?: () => void;
+}) {
+ const config = useAtomValue(
+ serverEnvironment.configValueAtom(props.environmentId ?? NO_ENVIRONMENT),
+ );
+ // Absent on upstream servers and on fork servers from before the stream
+ // shipped, so hide rather than probe.
+ const supported =
+ props.environmentId !== null && config?.environment.capabilities.providerRateLimits === true;
+ const { data } = useEnvironmentQuery(
+ supported && props.environmentId !== null
+ ? serverEnvironment.providerRateLimits({ environmentId: props.environmentId, input: {} })
+ : null,
+ );
+
+ const rows = useMemo(() => {
+ if (data === null) return [];
+ return buildProviderRateLimitRows({
+ providers: config?.providers ?? [],
+ entries: data.entries,
+ now: Date.now(),
+ });
+ }, [config?.providers, data]);
+
+ const visible = rows.filter((row) => row.remainingPercent !== null);
+ if (!supported || visible.length === 0) return null;
+
+ return (
+
+ {visible.map((row) => (
+
+ ))}
+
+ );
+}
+
+// Atom families are keyed; a stable placeholder avoids minting one per render.
+const NO_ENVIRONMENT = "__phase-sidebar-no-environment__" as EnvironmentId;
diff --git a/apps/mobile/src/features/phasesidebar/PhaseSidebarRowView.tsx b/apps/mobile/src/features/phasesidebar/PhaseSidebarRowView.tsx
new file mode 100644
index 000000000000..5c3b119e604d
--- /dev/null
+++ b/apps/mobile/src/features/phasesidebar/PhaseSidebarRowView.tsx
@@ -0,0 +1,208 @@
+// T3-CUSTOM(expbkt3): one thread row in the mobile phase sidebar.
+//
+// The metadata lane is the whole point of this sidebar, so it carries the same
+// facts as web: repository, worktree codename, Linear tag, Mattermost mark, PR
+// number, priority, owner, provider and relative time. Every one of those is
+// resolved by client-runtime; this component only lays them out.
+import {
+ compactPhaseSidebarTimeLabel,
+ formatThreadPriority,
+ phaseSidebarRowOwnerAvatarUserId,
+ phaseSidebarWorktreeRowProps,
+ resolvePhaseSidebarLinearIssue,
+ resolvePhaseSidebarMattermostLink,
+ resolvePhaseSidebarProviderCode,
+ type PhaseSidebarRow,
+ type PhaseSidebarWorktreeView,
+} from "@t3tools/client-runtime/state/phase-sidebar";
+import { worktreeCodenameToneIndex } from "@t3tools/shared/worktreeCodename";
+import type { UserId } from "@t3tools/contracts";
+import type { MenuAction, NativeActionEvent } from "@react-native-menu/menu";
+import { memo, useCallback, type ReactNode } from "react";
+import { Pressable, View, type LayoutChangeEvent } from "react-native";
+
+import { AppText as Text } from "../../components/AppText";
+import { ControlPillMenu } from "../../components/ControlPill";
+import { cn } from "../../lib/cn";
+import {
+ phaseSidebarCheckoutToneClassName,
+ phaseSidebarPriorityToneClassName,
+} from "./phaseSidebarRowTone";
+
+export interface PhaseSidebarRowViewProps {
+ readonly row: PhaseSidebarRow;
+ /** Whose avatar to omit: the row shows only *other* people's. */
+ readonly viewerUserId: UserId | null;
+ readonly worktreeView: PhaseSidebarWorktreeView;
+ readonly indentDepth: number;
+ readonly isActive: boolean;
+ readonly subtreeCount: number;
+ readonly isExpanded: boolean;
+ readonly onPress: (row: PhaseSidebarRow) => void;
+ /** Mutable because MenuView's prop type is not readonly. */
+ readonly actions: MenuAction[];
+ readonly onPressAction: (row: PhaseSidebarRow, actionId: string) => void;
+ /** Reports this row's box so a drop can be resolved without measuring. */
+ readonly onLayoutGeometry?: (key: string, y: number, height: number, depth: number) => void;
+ readonly rowKey: string;
+ readonly isDragging?: boolean;
+ readonly isDropTarget?: boolean;
+ readonly dropRejectionLabel?: string | null;
+ /**
+ * The grab affordance, supplied by the list because it owns the gesture.
+ * Long-press is already the context menu, so dragging needs its own target.
+ */
+ readonly dragHandle?: ReactNode;
+ readonly onToggleExpanded: (row: PhaseSidebarRow) => void;
+}
+
+/** One indent step, kept small: a phone has no horizontal room to spare. */
+const INDENT_STEP = 14;
+
+export const PhaseSidebarRowView = memo(function PhaseSidebarRowView(
+ props: PhaseSidebarRowViewProps,
+) {
+ const { row, worktreeView } = props;
+ const thread = row.thread;
+ const worktree = phaseSidebarWorktreeRowProps(worktreeView, thread.worktreePath);
+ const linearIssue = row.linearIssueSupported
+ ? resolvePhaseSidebarLinearIssue(thread.branch, thread.linearIssueUrl)
+ : null;
+ const mattermost = row.mattermostLinkSupported
+ ? resolvePhaseSidebarMattermostLink(thread.mattermostThreadUrl)
+ : null;
+ const ownerAvatarUserId = phaseSidebarRowOwnerAvatarUserId({
+ ownerUserId: thread.ownerUserId,
+ currentUserId: props.viewerUserId,
+ });
+ const providerCode = resolvePhaseSidebarProviderCode(row.providerKind);
+ const priority = thread.priority ?? null;
+
+ const handlePress = useCallback(() => props.onPress(row), [props, row]);
+ const handleLayout = useCallback(
+ (event: LayoutChangeEvent) => {
+ const { y, height } = event.nativeEvent.layout;
+ props.onLayoutGeometry?.(props.rowKey, y, height, props.indentDepth);
+ },
+ [props],
+ );
+ const handlePressAction = useCallback(
+ (event: NativeActionEvent) => props.onPressAction(row, event.nativeEvent.event),
+ [props, row],
+ );
+ const handleToggle = useCallback(() => props.onToggleExpanded(row), [props, row]);
+
+ return (
+
+
+ {props.subtreeCount > 0 ? (
+
+
+ {props.isExpanded ? "⌄" : "›"}
+
+
+ {props.subtreeCount}
+
+
+ ) : null}
+
+ {row.isUnreadCompletion ? (
+
+ ) : null}
+
+
+
+
+ {thread.title}
+
+
+ {compactPhaseSidebarTimeLabel(thread.updatedAt)}
+
+
+
+ {/* The metadata lane. Order matches web so the two read the same. */}
+
+
+ {row.repositoryLabel}
+
+ {worktree.worktreeCodename === null ? null : (
+
+ {worktree.worktreeCodename}
+ {worktree.worktreeSharedCount > 0 ? ` ×${worktree.worktreeSharedCount}` : ""}
+
+ )}
+ {linearIssue === null ? null : (
+
+ {linearIssue.identifier}
+
+ )}
+ {mattermost === null ? null : (
+
+ mm
+
+ )}
+
+
+
+ {priority === null || !row.prioritySupported ? null : (
+
+ {formatThreadPriority(priority)}
+
+ )}
+ {ownerAvatarUserId === null ? null : (
+
+
+ {ownerAvatarUserId.slice(0, 1).toUpperCase()}
+
+
+ )}
+
+ {providerCode}
+
+ {props.dragHandle}
+
+
+
+
+ );
+});
diff --git a/apps/mobile/src/features/phasesidebar/phaseSidebarDrag.test.ts b/apps/mobile/src/features/phasesidebar/phaseSidebarDrag.test.ts
new file mode 100644
index 000000000000..b20f7ee6fbbc
--- /dev/null
+++ b/apps/mobile/src/features/phasesidebar/phaseSidebarDrag.test.ts
@@ -0,0 +1,227 @@
+// T3-CUSTOM(expbkt3): fork-owned coverage for the drag drop rules.
+//
+// A bad drop re-parents real work, so these are exhaustive about the refusals
+// rather than only the happy path.
+import { describe, expect, it } from "@effect/vitest";
+import type { PhaseSidebarRow } from "@t3tools/client-runtime/state/phase-sidebar";
+
+import { resolveDragIntent } from "./usePhaseSidebarDrag";
+import {
+ describePhaseSidebarDropRejection,
+ measureSubtreeDepth,
+ validateReorder,
+ validateReparent,
+} from "./phaseSidebarDrag";
+
+const row = (
+ id: string,
+ overrides: {
+ parentThreadId?: string | null;
+ environmentId?: string;
+ pinnedAt?: string | null;
+ } = {},
+): PhaseSidebarRow =>
+ ({
+ thread: {
+ id,
+ environmentId: overrides.environmentId ?? "env-1",
+ parentThreadId: overrides.parentThreadId ?? null,
+ pinnedAt: overrides.pinnedAt ?? null,
+ },
+ }) as PhaseSidebarRow;
+
+describe("validateReparent", () => {
+ it("allows a plain root-to-child move", () => {
+ const subject = row("a");
+ const target = row("b");
+ expect(
+ validateReparent({ subject, target, allRows: [subject, target], targetDepth: 0 }),
+ ).toEqual({ allowed: true });
+ });
+
+ it("refuses dropping a row on itself", () => {
+ const subject = row("a");
+ expect(
+ validateReparent({ subject, target: subject, allRows: [subject], targetDepth: 0 }),
+ ).toMatchObject({ allowed: false, reason: "same-thread" });
+ });
+
+ it("refuses a cross-environment parent", () => {
+ const subject = row("a");
+ const target = row("b", { environmentId: "env-2" });
+ expect(
+ validateReparent({ subject, target, allRows: [subject, target], targetDepth: 0 }),
+ ).toMatchObject({ allowed: false, reason: "cross-environment" });
+ });
+
+ it("refuses a move to the parent it already has", () => {
+ const subject = row("a", { parentThreadId: "b" });
+ const target = row("b");
+ expect(
+ validateReparent({ subject, target, allRows: [subject, target], targetDepth: 0 }),
+ ).toMatchObject({ allowed: false, reason: "already-parent" });
+ });
+
+ it("refuses nesting a row under its own descendant", () => {
+ const subject = row("a");
+ const child = row("b", { parentThreadId: "a" });
+ const grandchild = row("c", { parentThreadId: "b" });
+ expect(
+ validateReparent({
+ subject,
+ target: grandchild,
+ allRows: [subject, child, grandchild],
+ targetDepth: 2,
+ }),
+ ).toMatchObject({ allowed: false, reason: "own-descendant" });
+ });
+
+ it("refuses a drop that would exceed the maximum tree depth", () => {
+ const subject = row("a");
+ const target = row("b");
+ expect(
+ validateReparent({ subject, target, allRows: [subject, target], targetDepth: 16 }),
+ ).toMatchObject({ allowed: false, reason: "too-deep" });
+ });
+
+ it("counts the dragged subtree against the depth limit, not just the row", () => {
+ const subject = row("a");
+ const child = row("a1", { parentThreadId: "a" });
+ const target = row("b");
+ const allRows = [subject, child, target];
+ // Depth 14 + 1 for the move + 1 level of subtree = 16, still allowed.
+ expect(validateReparent({ subject, target, allRows, targetDepth: 14 })).toEqual({
+ allowed: true,
+ });
+ // One deeper overflows.
+ expect(validateReparent({ subject, target, allRows, targetDepth: 15 })).toMatchObject({
+ allowed: false,
+ reason: "too-deep",
+ });
+ });
+
+ it("treats a null target as 'make this a root thread'", () => {
+ const nested = row("a", { parentThreadId: "b" });
+ expect(
+ validateReparent({ subject: nested, target: null, allRows: [nested], targetDepth: 0 }),
+ ).toEqual({ allowed: true });
+ });
+
+ it("refuses unparenting a row that is already a root", () => {
+ const rootRow = row("a");
+ expect(
+ validateReparent({ subject: rootRow, target: null, allRows: [rootRow], targetDepth: 0 }),
+ ).toMatchObject({ allowed: false, reason: "already-parent" });
+ });
+});
+
+describe("measureSubtreeDepth", () => {
+ it("is 0 for a leaf", () => {
+ expect(measureSubtreeDepth([row("a")], "a")).toBe(0);
+ });
+
+ it("counts the deepest branch", () => {
+ const rows = [
+ row("a"),
+ row("b", { parentThreadId: "a" }),
+ row("c", { parentThreadId: "b" }),
+ row("d", { parentThreadId: "a" }),
+ ];
+ expect(measureSubtreeDepth(rows, "a")).toBe(2);
+ });
+
+ it("does not hang on a cycle in server data", () => {
+ const rows = [row("a", { parentThreadId: "b" }), row("b", { parentThreadId: "a" })];
+ expect(measureSubtreeDepth(rows, "a")).toBeLessThan(10);
+ });
+});
+
+describe("validateReorder", () => {
+ it("refuses to reorder an unpinned row", () => {
+ expect(
+ validateReorder({ subject: row("a"), target: row("b", { pinnedAt: "t" }) }),
+ ).toMatchObject({ allowed: false, reason: "not-pinned" });
+ });
+
+ it("refuses to reorder against an unpinned target", () => {
+ expect(
+ validateReorder({ subject: row("a", { pinnedAt: "t" }), target: row("b") }),
+ ).toMatchObject({ allowed: false, reason: "not-pinned" });
+ });
+
+ it("allows reordering two pinned rows", () => {
+ expect(
+ validateReorder({
+ subject: row("a", { pinnedAt: "t" }),
+ target: row("b", { pinnedAt: "t" }),
+ }),
+ ).toEqual({ allowed: true });
+ });
+
+ it("refuses a cross-environment reorder", () => {
+ expect(
+ validateReorder({
+ subject: row("a", { pinnedAt: "t" }),
+ target: row("b", { pinnedAt: "t", environmentId: "env-2" }),
+ }),
+ ).toMatchObject({ allowed: false, reason: "cross-environment" });
+ });
+});
+
+describe("describePhaseSidebarDropRejection", () => {
+ it("has wording for every refusal", () => {
+ for (const reason of [
+ "same-thread",
+ "cross-environment",
+ "own-descendant",
+ "already-parent",
+ "too-deep",
+ "not-pinned",
+ ] as const) {
+ expect(describePhaseSidebarDropRejection(reason).length).toBeGreaterThan(0);
+ }
+ });
+});
+
+describe("resolveDragIntent", () => {
+ const rows = [
+ { key: "a", geometry: { y: 0, height: 60, depth: 0 } },
+ { key: "b", geometry: { y: 60, height: 60, depth: 1 } },
+ ];
+
+ it("re-parents when the finger is over the middle of a row", () => {
+ expect(resolveDragIntent({ pointerY: 30, rows })).toEqual({
+ kind: "reparent",
+ targetKey: "a",
+ });
+ });
+
+ it("reorders near the top edge of a row", () => {
+ expect(resolveDragIntent({ pointerY: 2, rows })).toEqual({ kind: "reorder", targetKey: "a" });
+ });
+
+ it("reorders near the bottom edge of a row", () => {
+ expect(resolveDragIntent({ pointerY: 58, rows })).toEqual({ kind: "reorder", targetKey: "a" });
+ });
+
+ it("resolves against the right row when several are stacked", () => {
+ expect(resolveDragIntent({ pointerY: 90, rows })).toEqual({
+ kind: "reparent",
+ targetKey: "b",
+ });
+ });
+
+ it("drops to root past the last row", () => {
+ expect(resolveDragIntent({ pointerY: 500, rows })).toEqual({
+ kind: "reparent",
+ targetKey: null,
+ });
+ });
+
+ it("drops to root when there are no rows at all", () => {
+ expect(resolveDragIntent({ pointerY: 10, rows: [] })).toEqual({
+ kind: "reparent",
+ targetKey: null,
+ });
+ });
+});
diff --git a/apps/mobile/src/features/phasesidebar/phaseSidebarDrag.ts b/apps/mobile/src/features/phasesidebar/phaseSidebarDrag.ts
new file mode 100644
index 000000000000..4fd156baed29
--- /dev/null
+++ b/apps/mobile/src/features/phasesidebar/phaseSidebarDrag.ts
@@ -0,0 +1,144 @@
+// T3-CUSTOM(expbkt3): pure rules for dragging a row onto another.
+//
+// Kept free of gesture and react-native imports so the rules are testable
+// without a renderer — a bad drop re-parents real work, so this is the part that
+// has to be right before any animation exists.
+//
+// Two drop kinds, matching the web sidebar's two operations: dropping ON a row
+// re-parents (move-under), dropping BETWEEN rows reorders a pin.
+import {
+ collectDescendantThreadIds,
+ type PhaseSidebarRow,
+} from "@t3tools/client-runtime/state/phase-sidebar";
+import { PHASE_SIDEBAR_TREE_MAX_DEPTH } from "@t3tools/client-runtime/state/phase-sidebar-tree";
+
+export type PhaseSidebarDropTarget =
+ | { readonly kind: "reparent"; readonly parentKey: string | null }
+ | { readonly kind: "reorder"; readonly beforeKey: string | null };
+
+export type PhaseSidebarDropRejection =
+ | "same-thread"
+ | "cross-environment"
+ | "own-descendant"
+ | "already-parent"
+ | "too-deep"
+ | "not-pinned";
+
+export type PhaseSidebarDropVerdict =
+ | { readonly allowed: true }
+ | { readonly allowed: false; readonly reason: PhaseSidebarDropRejection };
+
+const ALLOWED: PhaseSidebarDropVerdict = { allowed: true };
+const reject = (reason: PhaseSidebarDropRejection): PhaseSidebarDropVerdict => ({
+ allowed: false,
+ reason,
+});
+
+/**
+ * Whether `subject` may be re-parented under `target`.
+ *
+ * Rejects rather than clamps: a drop the reviewer did not intend is worse than
+ * a drop that visibly refuses. Order matters — the cheapest identity checks run
+ * before the descendant walk.
+ */
+export function validateReparent(input: {
+ readonly subject: PhaseSidebarRow;
+ readonly target: PhaseSidebarRow | null;
+ readonly allRows: ReadonlyArray;
+ /** Depth of the prospective parent, 0 for a root row. */
+ readonly targetDepth: number;
+}): PhaseSidebarDropVerdict {
+ const subject = input.subject.thread;
+
+ // Dropping on empty space means "make this a root thread".
+ if (input.target === null) {
+ return (subject.parentThreadId ?? null) === null ? reject("already-parent") : ALLOWED;
+ }
+
+ const target = input.target.thread;
+ if (target.id === subject.id) return reject("same-thread");
+ // Lineage is per environment: a thread cannot parent one on another server.
+ if (target.environmentId !== subject.environmentId) return reject("cross-environment");
+ if ((subject.parentThreadId ?? null) === target.id) return reject("already-parent");
+
+ // The subject would become its own ancestor, which would orphan the subtree.
+ const descendants = collectDescendantThreadIds(
+ input.allRows.map((row) => row.thread),
+ subject.id,
+ );
+ if (descendants.has(target.id)) return reject("own-descendant");
+
+ // The subject's own subtree moves with it, so the deepest leaf decides.
+ const subjectSubtreeDepth = measureSubtreeDepth(input.allRows, subject.id);
+ if (input.targetDepth + 1 + subjectSubtreeDepth > PHASE_SIDEBAR_TREE_MAX_DEPTH) {
+ return reject("too-deep");
+ }
+
+ return ALLOWED;
+}
+
+/** How many levels sit below `threadId`; 0 when it is a leaf. */
+export function measureSubtreeDepth(
+ rows: ReadonlyArray,
+ threadId: string,
+): number {
+ const childrenByParent = new Map();
+ for (const row of rows) {
+ const parent = row.thread.parentThreadId ?? null;
+ if (parent === null) continue;
+ const bucket = childrenByParent.get(parent);
+ if (bucket) bucket.push(row.thread.id);
+ else childrenByParent.set(parent, [row.thread.id]);
+ }
+
+ const walk = (id: string, seen: ReadonlySet): number => {
+ const children = childrenByParent.get(id) ?? [];
+ let deepest = 0;
+ for (const child of children) {
+ // Defensive: a cycle in server data must not hang the gesture.
+ if (seen.has(child)) continue;
+ deepest = Math.max(deepest, 1 + walk(child, new Set([...seen, child])));
+ }
+ return deepest;
+ };
+
+ return walk(threadId, new Set([threadId]));
+}
+
+/**
+ * Whether `subject` may be reordered to sit before `beforeKey`.
+ *
+ * Reordering is a pin operation, so an unpinned row has nothing to reorder —
+ * the affordance is hidden rather than offered and then refused.
+ */
+export function validateReorder(input: {
+ readonly subject: PhaseSidebarRow;
+ readonly target: PhaseSidebarRow | null;
+}): PhaseSidebarDropVerdict {
+ if (input.subject.thread.pinnedAt == null) return reject("not-pinned");
+ if (input.target === null) return ALLOWED;
+ if (input.target.thread.id === input.subject.thread.id) return reject("same-thread");
+ if (input.target.thread.environmentId !== input.subject.thread.environmentId) {
+ return reject("cross-environment");
+ }
+ if (input.target.thread.pinnedAt == null) return reject("not-pinned");
+ return ALLOWED;
+}
+
+/** Wording for the drag overlay when a drop is refused. */
+export function describePhaseSidebarDropRejection(reason: PhaseSidebarDropRejection): string {
+ switch (reason) {
+ case "same-thread":
+ return "Already here";
+ case "cross-environment":
+ return "Different environment";
+ case "own-descendant":
+ return "Cannot nest under its own child";
+ case "already-parent":
+ return "Already there";
+ case "too-deep":
+ return "Nested too deep";
+ case "not-pinned":
+ return "Only pinned threads reorder";
+ }
+}
diff --git a/apps/mobile/src/features/phasesidebar/phaseSidebarEnabled.ts b/apps/mobile/src/features/phasesidebar/phaseSidebarEnabled.ts
new file mode 100644
index 000000000000..cdc6f3d6d6d6
--- /dev/null
+++ b/apps/mobile/src/features/phasesidebar/phaseSidebarEnabled.ts
@@ -0,0 +1,19 @@
+// T3-CUSTOM(expbkt3): the experimental phase-sidebar opt-in, as a hook.
+//
+// Mirrors features/threads/use-thread-list-v2-enabled.ts. The resolver itself
+// lives in phaseSidebarPreferences.ts so it stays testable without pulling
+// react-native onto the import graph.
+import { useAtomValue } from "@effect/atom-react";
+import { AsyncResult } from "effect/unstable/reactivity";
+
+import { mobilePreferencesAtom } from "../../state/preferences";
+import { resolvePhaseSidebarEnabled } from "./phaseSidebarPreferences";
+
+export function usePhaseSidebarEnabled(): boolean {
+ const preferencesResult = useAtomValue(mobilePreferencesAtom);
+ const loaded = AsyncResult.isSuccess(preferencesResult);
+ return resolvePhaseSidebarEnabled({
+ preference: loaded ? preferencesResult.value.experimentalPhaseSidebarEnabled : undefined,
+ preferencesLoaded: loaded,
+ });
+}
diff --git a/apps/mobile/src/features/phasesidebar/phaseSidebarPreferences.test.ts b/apps/mobile/src/features/phasesidebar/phaseSidebarPreferences.test.ts
new file mode 100644
index 000000000000..f52ddc804fe3
--- /dev/null
+++ b/apps/mobile/src/features/phasesidebar/phaseSidebarPreferences.test.ts
@@ -0,0 +1,47 @@
+// T3-CUSTOM(expbkt3): fork-owned coverage for the sidebar's pure preference logic.
+import { describe, expect, it } from "@effect/vitest";
+
+import { pruneVisitTimestamps, resolvePhaseSidebarEnabled } from "./phaseSidebarPreferences";
+
+const at = (hour: number) => `2026-08-31T${String(hour).padStart(2, "0")}:00:00.000Z`;
+
+describe("pruneVisitTimestamps", () => {
+ it("leaves a map that fits under the cap untouched", () => {
+ const visits = { a: at(1), b: at(2) };
+ expect(pruneVisitTimestamps(visits, 5)).toBe(visits);
+ });
+
+ it("keeps the most recently visited threads when over the cap", () => {
+ const pruned = pruneVisitTimestamps({ old: at(1), mid: at(2), fresh: at(3) }, 2);
+ expect(Object.keys(pruned).sort()).toEqual(["fresh", "mid"]);
+ });
+
+ it("drops unparseable timestamps before real ones", () => {
+ const pruned = pruneVisitTimestamps({ broken: "not-a-date", real: at(1) }, 1);
+ expect(Object.keys(pruned)).toEqual(["real"]);
+ });
+
+ it("keeps exactly the cap when trimming", () => {
+ const visits = Object.fromEntries(
+ Array.from({ length: 10 }, (_, index) => [`t${index}`, at(index)]),
+ );
+ expect(Object.keys(pruneVisitTimestamps(visits, 4))).toHaveLength(4);
+ });
+});
+
+describe("resolvePhaseSidebarEnabled", () => {
+ it("is off while preferences are still loading", () => {
+ expect(resolvePhaseSidebarEnabled({ preference: true, preferencesLoaded: false })).toBe(false);
+ });
+
+ it("is off for a device that has never chosen", () => {
+ expect(resolvePhaseSidebarEnabled({ preference: undefined, preferencesLoaded: true })).toBe(
+ false,
+ );
+ });
+
+ it("is on only when explicitly enabled", () => {
+ expect(resolvePhaseSidebarEnabled({ preference: true, preferencesLoaded: true })).toBe(true);
+ expect(resolvePhaseSidebarEnabled({ preference: false, preferencesLoaded: true })).toBe(false);
+ });
+});
diff --git a/apps/mobile/src/features/phasesidebar/phaseSidebarPreferences.ts b/apps/mobile/src/features/phasesidebar/phaseSidebarPreferences.ts
new file mode 100644
index 000000000000..a9bbc270de5f
--- /dev/null
+++ b/apps/mobile/src/features/phasesidebar/phaseSidebarPreferences.ts
@@ -0,0 +1,53 @@
+// T3-CUSTOM(expbkt3): pure preference logic for the experimental phase sidebar.
+//
+// Deliberately free of react-native and of the preferences atom, mirroring the
+// split between features/threads/threadListV2.ts and its hook: anything that
+// imports the atom transitively imports react-native, which the unit test
+// bundler cannot parse. Hooks live in phaseSidebarEnabled.ts and
+// phaseSidebarVisitStore.ts.
+
+/**
+ * How many threads' visit times to remember. The map exists only to answer "is
+ * this row unread", a question about recent work, so old entries are worthless
+ * and an unbounded map would bloat the preferences blob.
+ */
+export const PHASE_SIDEBAR_VISIT_CAP = 200;
+
+/**
+ * Off by default, and off while preferences are still loading.
+ *
+ * The opposite default to Thread List v2 on purpose: this is experimental, so a
+ * device that has never chosen keeps the stock list, and the first frame after
+ * launch must not flash the experimental sidebar before the real answer loads.
+ */
+export function resolvePhaseSidebarEnabled(input: {
+ readonly preference: boolean | undefined;
+ readonly preferencesLoaded: boolean;
+}): boolean {
+ if (!input.preferencesLoaded) return false;
+ return input.preference === true;
+}
+
+/**
+ * Drops the oldest entries once the cap is exceeded.
+ *
+ * Getting this wrong either grows the blob forever or silently forgets threads
+ * the user just opened, so it is tested directly.
+ */
+export function pruneVisitTimestamps(
+ visits: Readonly>,
+ cap: number = PHASE_SIDEBAR_VISIT_CAP,
+): Readonly> {
+ const entries = Object.entries(visits);
+ if (entries.length <= cap) return visits;
+ const newestFirst = entries.sort((left, right) => {
+ const leftMs = Date.parse(left[1]);
+ const rightMs = Date.parse(right[1]);
+ // An unparseable timestamp sorts last, so it is dropped before a real one.
+ if (Number.isNaN(leftMs) && Number.isNaN(rightMs)) return 0;
+ if (Number.isNaN(leftMs)) return 1;
+ if (Number.isNaN(rightMs)) return -1;
+ return rightMs - leftMs;
+ });
+ return Object.fromEntries(newestFirst.slice(0, cap));
+}
diff --git a/apps/mobile/src/features/phasesidebar/phaseSidebarRowTone.ts b/apps/mobile/src/features/phasesidebar/phaseSidebarRowTone.ts
new file mode 100644
index 000000000000..26cf106d349b
--- /dev/null
+++ b/apps/mobile/src/features/phasesidebar/phaseSidebarRowTone.ts
@@ -0,0 +1,72 @@
+// T3-CUSTOM(expbkt3): mobile's class strings for the phase sidebar.
+//
+// The web sidebar keeps its own equivalents under apps/web because Tailwind only
+// scans source there. Mobile's uniwind scans apps/mobile, so the class strings
+// have to appear literally in this file — which is why these are duplicated by
+// design rather than shared. Everything that *decides* a tone is shared; only
+// the literal class names live here.
+import type { PhaseSidebarPhaseId } from "@t3tools/client-runtime/state/phase-sidebar";
+import type { ChangeRequestStateLike } from "@t3tools/client-runtime/state/thread-settled";
+
+/** Lifecycle header tint, matching the web sidebar's phase hues. */
+export function phaseSidebarSectionToneClassName(phaseId: PhaseSidebarPhaseId): string {
+ switch (phaseId) {
+ case "needs_input":
+ return "text-amber-600 dark:text-amber-300";
+ case "plan_ready":
+ return "text-violet-700 dark:text-violet-300";
+ case "ready":
+ return "text-emerald-700 dark:text-emerald-300";
+ case "planning":
+ return "text-indigo-700 dark:text-indigo-300";
+ case "implementing":
+ return "text-sky-700 dark:text-sky-300";
+ }
+}
+
+/**
+ * Priority pill. P0 reads as urgent and desaturates as it descends, so a screen
+ * full of P2s does not shout.
+ */
+export function phaseSidebarPriorityToneClassName(priority: number): string {
+ if (priority <= 0) return "bg-orange-500 text-white";
+ if (priority === 1) return "bg-amber-500 text-white";
+ if (priority === 2) return "bg-amber-700/70 text-white";
+ return "bg-muted text-muted-foreground";
+}
+
+/**
+ * PR state by colour alone, matching web: green open, violet merged, red closed.
+ * State words stay out of the lane — it is already the densest text on screen.
+ */
+export function phaseSidebarChangeRequestToneClassName(state: ChangeRequestStateLike): string {
+ switch (state) {
+ case "merged":
+ return "text-violet-600 dark:text-violet-300";
+ case "closed":
+ return "text-rose-600 dark:text-rose-300";
+ default:
+ return "text-emerald-600 dark:text-emerald-300";
+ }
+}
+
+/** Worktree codename tint. Mirrors web's static tone table, same 12 hues. */
+const CHECKOUT_TONES: readonly string[] = [
+ "text-rose-600 dark:text-rose-300/90",
+ "text-orange-600 dark:text-orange-300/90",
+ "text-amber-600 dark:text-amber-300/90",
+ "text-lime-600 dark:text-lime-300/90",
+ "text-emerald-600 dark:text-emerald-300/90",
+ "text-teal-600 dark:text-teal-300/90",
+ "text-cyan-600 dark:text-cyan-300/90",
+ "text-sky-600 dark:text-sky-300/90",
+ "text-indigo-600 dark:text-indigo-300/90",
+ "text-violet-600 dark:text-violet-300/90",
+ "text-fuchsia-600 dark:text-fuchsia-300/90",
+ "text-pink-600 dark:text-pink-300/90",
+];
+
+export function phaseSidebarCheckoutToneClassName(toneIndex: number | null): string {
+ if (toneIndex === null) return "text-muted-foreground";
+ return CHECKOUT_TONES[toneIndex % CHECKOUT_TONES.length] ?? "text-muted-foreground";
+}
diff --git a/apps/mobile/src/features/phasesidebar/phaseSidebarVisitStore.ts b/apps/mobile/src/features/phasesidebar/phaseSidebarVisitStore.ts
new file mode 100644
index 000000000000..f3dde04b26b8
--- /dev/null
+++ b/apps/mobile/src/features/phasesidebar/phaseSidebarVisitStore.ts
@@ -0,0 +1,38 @@
+// T3-CUSTOM(expbkt3): remembers when each thread was last looked at.
+//
+// Feeds `buildPhaseSidebarRows`, which turns it into the row's unread dot via
+// the shared `hasUnseenCompletion`. Persisted in device preferences rather than
+// synced: "have *I* seen this" is per device, and mobile has no client-settings
+// sync to hang it on. Pruning lives in phaseSidebarPreferences.ts.
+import { useAtomSet, useAtomValue } from "@effect/atom-react";
+import { AsyncResult } from "effect/unstable/reactivity";
+import { useCallback } from "react";
+
+import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
+import { pruneVisitTimestamps } from "./phaseSidebarPreferences";
+
+const EMPTY_VISITS: Readonly> = {};
+
+export function usePhaseSidebarVisitTimestamps(): Readonly> {
+ const result = useAtomValue(mobilePreferencesAtom);
+ return AsyncResult.isSuccess(result)
+ ? (result.value.phaseSidebarVisitedAt ?? EMPTY_VISITS)
+ : EMPTY_VISITS;
+}
+
+/** Records a visit. Call it when a thread is opened, not when it is rendered. */
+export function useMarkPhaseSidebarThreadVisited(): (threadKey: string) => void {
+ const visits = usePhaseSidebarVisitTimestamps();
+ const updatePreferences = useAtomSet(updateMobilePreferencesAtom);
+ return useCallback(
+ (threadKey: string) => {
+ updatePreferences({
+ phaseSidebarVisitedAt: pruneVisitTimestamps({
+ ...visits,
+ [threadKey]: new Date().toISOString(),
+ }),
+ });
+ },
+ [updatePreferences, visits],
+ );
+}
diff --git a/apps/mobile/src/features/phasesidebar/usePhaseSidebarDrag.ts b/apps/mobile/src/features/phasesidebar/usePhaseSidebarDrag.ts
new file mode 100644
index 000000000000..8912924925e6
--- /dev/null
+++ b/apps/mobile/src/features/phasesidebar/usePhaseSidebarDrag.ts
@@ -0,0 +1,165 @@
+// T3-CUSTOM(expbkt3): the drag interaction for reordering and re-parenting rows.
+//
+// Long-press to lift, then pan. That two-stage gesture is deliberate: a pan that
+// activates immediately fights the list's own scroll, which is the most common
+// way touch drag-and-drop feels broken.
+//
+// Row geometry is collected by onLayout rather than measured on drop: measuring
+// mid-gesture costs a round trip to the native side per frame.
+import type { PhaseSidebarRow } from "@t3tools/client-runtime/state/phase-sidebar";
+import { useCallback, useMemo, useRef, useState } from "react";
+
+import {
+ describePhaseSidebarDropRejection,
+ validateReorder,
+ validateReparent,
+ type PhaseSidebarDropVerdict,
+} from "./phaseSidebarDrag";
+
+/** Where a row sits in the list, in list-content coordinates. */
+export interface PhaseSidebarRowGeometry {
+ readonly y: number;
+ readonly height: number;
+ readonly depth: number;
+}
+
+export type PhaseSidebarDragIntent =
+ | { readonly kind: "reparent"; readonly targetKey: string | null }
+ | { readonly kind: "reorder"; readonly targetKey: string };
+
+export interface PhaseSidebarDragState {
+ readonly subjectKey: string;
+ readonly intent: PhaseSidebarDragIntent;
+ readonly verdict: PhaseSidebarDropVerdict;
+ /** Null while the drop is allowed; wording for the overlay otherwise. */
+ readonly rejectionLabel: string | null;
+}
+
+/**
+ * How much of a row's height counts as its "edges".
+ *
+ * A drop in the middle band re-parents; the top and bottom bands reorder. A
+ * third each keeps both operations reachable with a thumb — narrower edges are
+ * unhittable while scrolling settles.
+ */
+const EDGE_BAND = 1 / 3;
+
+export function resolveDragIntent(input: {
+ readonly pointerY: number;
+ readonly rows: ReadonlyArray<{
+ readonly key: string;
+ readonly geometry: PhaseSidebarRowGeometry;
+ }>;
+}): PhaseSidebarDragIntent {
+ for (const entry of input.rows) {
+ const { y, height } = entry.geometry;
+ if (input.pointerY < y || input.pointerY > y + height) continue;
+ const offset = (input.pointerY - y) / Math.max(height, 1);
+ if (offset <= EDGE_BAND || offset >= 1 - EDGE_BAND) {
+ return { kind: "reorder", targetKey: entry.key };
+ }
+ return { kind: "reparent", targetKey: entry.key };
+ }
+ // Past the last row: drop to root.
+ return { kind: "reparent", targetKey: null };
+}
+
+export interface PhaseSidebarDragController {
+ readonly drag: PhaseSidebarDragState | null;
+ readonly registerGeometry: (key: string, geometry: PhaseSidebarRowGeometry) => void;
+ readonly beginDrag: (subjectKey: string) => void;
+ readonly updateDrag: (pointerY: number) => void;
+ /** Commits an allowed drop; a refused one is discarded. */
+ readonly endDrag: () => void;
+ readonly cancelDrag: () => void;
+}
+
+export function usePhaseSidebarDrag(input: {
+ readonly rows: ReadonlyArray;
+ readonly rowKeyFor: (row: PhaseSidebarRow) => string;
+ readonly onReparent: (subject: PhaseSidebarRow, parent: PhaseSidebarRow | null) => void;
+ readonly onReorder: (subject: PhaseSidebarRow, before: PhaseSidebarRow) => void;
+}): PhaseSidebarDragController {
+ const geometry = useRef(new Map());
+ const [drag, setDrag] = useState(null);
+ const { rows, rowKeyFor, onReparent, onReorder } = input;
+
+ const rowsByKey = useMemo(() => {
+ const map = new Map();
+ for (const row of rows) map.set(rowKeyFor(row), row);
+ return map;
+ }, [rowKeyFor, rows]);
+
+ const registerGeometry = useCallback((key: string, next: PhaseSidebarRowGeometry) => {
+ geometry.current.set(key, next);
+ }, []);
+
+ const beginDrag = useCallback((subjectKey: string) => {
+ // Lifted but not yet over a valid target: the row is its own target, which
+ // is refused, so nothing commits if the finger lifts without moving.
+ setDrag({
+ subjectKey,
+ intent: { kind: "reparent", targetKey: subjectKey },
+ verdict: { allowed: false, reason: "same-thread" },
+ rejectionLabel: null,
+ });
+ }, []);
+
+ const updateDrag = useCallback(
+ (pointerY: number) => {
+ setDrag((current) => {
+ if (current === null) return null;
+ const subject = rowsByKey.get(current.subjectKey);
+ if (subject === undefined) return current;
+
+ const ordered = [...geometry.current.entries()]
+ .map(([key, value]) => ({ key, geometry: value }))
+ .sort((left, right) => left.geometry.y - right.geometry.y);
+ const intent = resolveDragIntent({ pointerY, rows: ordered });
+
+ const targetKey = intent.targetKey;
+ const target = targetKey === null ? null : (rowsByKey.get(targetKey) ?? null);
+ const verdict =
+ intent.kind === "reparent"
+ ? validateReparent({
+ subject,
+ target,
+ allRows: rows,
+ targetDepth: targetKey === null ? 0 : (geometry.current.get(targetKey)?.depth ?? 0),
+ })
+ : validateReorder({ subject, target });
+
+ return {
+ subjectKey: current.subjectKey,
+ intent,
+ verdict,
+ rejectionLabel: verdict.allowed
+ ? null
+ : describePhaseSidebarDropRejection(verdict.reason),
+ };
+ });
+ },
+ [rows, rowsByKey],
+ );
+
+ const endDrag = useCallback(() => {
+ setDrag((current) => {
+ if (current === null || !current.verdict.allowed) return null;
+ const subject = rowsByKey.get(current.subjectKey);
+ if (subject === undefined) return null;
+
+ if (current.intent.kind === "reparent") {
+ const parentKey = current.intent.targetKey;
+ onReparent(subject, parentKey === null ? null : (rowsByKey.get(parentKey) ?? null));
+ } else {
+ const before = rowsByKey.get(current.intent.targetKey);
+ if (before !== undefined) onReorder(subject, before);
+ }
+ return null;
+ });
+ }, [onReorder, onReparent, rowsByKey]);
+
+ const cancelDrag = useCallback(() => setDrag(null), []);
+
+ return { drag, registerGeometry, beginDrag, updateDrag, endDrag, cancelDrag };
+}
diff --git a/apps/mobile/src/features/phasesidebar/usePhaseSidebarRowActions.test.ts b/apps/mobile/src/features/phasesidebar/usePhaseSidebarRowActions.test.ts
new file mode 100644
index 000000000000..cbe282ce412b
--- /dev/null
+++ b/apps/mobile/src/features/phasesidebar/usePhaseSidebarRowActions.test.ts
@@ -0,0 +1,88 @@
+// T3-CUSTOM(expbkt3): fork-owned coverage for the row action set.
+import { describe, expect, it } from "@effect/vitest";
+import type { PhaseSidebarRow } from "@t3tools/client-runtime/state/phase-sidebar";
+
+import { buildPhaseSidebarRowActions } from "./usePhaseSidebarRowActions";
+
+const NOW = "2026-08-31T12:00:00.000Z";
+
+const row = (overrides: Partial = {}): PhaseSidebarRow =>
+ ({
+ thread: {
+ id: "t1",
+ session: null,
+ snoozedUntil: null,
+ settledOverride: null,
+ settledAt: null,
+ pinnedAt: null,
+ updatedAt: NOW,
+ ...overrides.thread,
+ },
+ settlementSupported: false,
+ snoozeSupported: false,
+ prioritySupported: false,
+ ...overrides,
+ }) as PhaseSidebarRow;
+
+const ids = (r: PhaseSidebarRow) =>
+ buildPhaseSidebarRowActions({ row: r, now: NOW }).map((action) => action.id);
+
+describe("buildPhaseSidebarRowActions", () => {
+ it("always offers People first — tagging is the common reason to long-press", () => {
+ expect(ids(row())[0]).toBe("people");
+ });
+
+ it("offers only People, pin and archive against a server with no lifecycle support", () => {
+ expect(ids(row())).toEqual(["people", "pin", "archive"]);
+ });
+
+ it("offers Settle when supported, and Reopen once settled", () => {
+ expect(ids(row({ settlementSupported: true }))).toContain("settle");
+ expect(
+ ids(row({ settlementSupported: true, thread: { settledOverride: "settled" } as never })),
+ ).toContain("unsettle");
+ });
+
+ it("offers Snooze when supported, and Wake while snoozed", () => {
+ expect(ids(row({ snoozeSupported: true }))).toContain("snooze");
+ expect(
+ ids(
+ row({
+ snoozeSupported: true,
+ thread: { snoozedUntil: "2099-01-01T00:00:00.000Z" } as never,
+ }),
+ ),
+ ).toContain("unsnooze");
+ });
+
+ it("flips pin to unpin for a pinned thread", () => {
+ expect(ids(row())).toContain("pin");
+ expect(ids(row({ thread: { pinnedAt: NOW } as never }))).toContain("unpin");
+ });
+
+ it("lists priority choices only when the server supports priority", () => {
+ expect(ids(row()).some((id) => id.startsWith("priority:"))).toBe(false);
+ expect(
+ ids(row({ prioritySupported: true })).filter((id) => id.startsWith("priority:")).length,
+ ).toBeGreaterThan(0);
+ });
+
+ it("offers force stop only while a session exists", () => {
+ expect(ids(row())).not.toContain("force-stop");
+ expect(ids(row({ thread: { session: { status: "running" } } as never }))).toContain(
+ "force-stop",
+ );
+ });
+
+ it("keeps the destructive actions last", () => {
+ const actions = buildPhaseSidebarRowActions({
+ row: row({ thread: { session: { status: "running" } } as never }),
+ now: NOW,
+ });
+ expect(actions.at(-1)?.id).toBe("archive");
+ expect(actions.filter((action) => action.destructive === true).map((a) => a.id)).toEqual([
+ "force-stop",
+ "archive",
+ ]);
+ });
+});
diff --git a/apps/mobile/src/features/phasesidebar/usePhaseSidebarRowActions.ts b/apps/mobile/src/features/phasesidebar/usePhaseSidebarRowActions.ts
new file mode 100644
index 000000000000..f597668805aa
--- /dev/null
+++ b/apps/mobile/src/features/phasesidebar/usePhaseSidebarRowActions.ts
@@ -0,0 +1,100 @@
+// T3-CUSTOM(expbkt3): the long-press action set for a sidebar row.
+//
+// Every action is capability-gated by the row model, so an older server simply
+// offers fewer items rather than failing an RPC. Each action that can be
+// entered can also be left — settle/unsettle, snooze/unsnooze, pin/unpin — per
+// the fork's rule that a one-way door is a bug.
+import {
+ phaseSidebarCanForceStopAgent,
+ PHASE_SIDEBAR_PRIORITY_CHOICES,
+ type PhaseSidebarRow,
+} from "@t3tools/client-runtime/state/phase-sidebar";
+import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
+
+export type PhaseSidebarRowActionId =
+ | "people"
+ | "settle"
+ | "unsettle"
+ | "snooze"
+ | "unsnooze"
+ | "pin"
+ | "unpin"
+ | "archive"
+ | "force-stop"
+ | `priority:${number}`;
+
+export interface PhaseSidebarRowAction {
+ readonly id: PhaseSidebarRowActionId;
+ readonly title: string;
+ /** SF Symbol name; Android falls back to its own mapping. */
+ readonly image: string;
+ readonly destructive?: boolean;
+}
+
+/**
+ * The actions to offer for one row, in the order a thumb should meet them:
+ * people first because tagging is the most common reason to long-press, then
+ * lifecycle, then the destructive ones last.
+ */
+export function buildPhaseSidebarRowActions(input: {
+ readonly row: PhaseSidebarRow;
+ readonly now: string;
+ /**
+ * Mobile has no auto-settle setting of its own, so null: a row is settled
+ * only when the server says so, never because a timer elapsed locally.
+ */
+ readonly autoSettleAfterDays?: number | null;
+}): ReadonlyArray {
+ const { row } = input;
+ const thread = row.thread;
+ const actions: PhaseSidebarRowAction[] = [{ id: "people", title: "People", image: "person.2" }];
+
+ if (row.settlementSupported) {
+ const settled = effectiveSettled(thread, {
+ now: input.now,
+ autoSettleAfterDays: input.autoSettleAfterDays ?? null,
+ });
+ actions.push(
+ settled
+ ? { id: "unsettle", title: "Reopen", image: "arrow.uturn.backward" }
+ : { id: "settle", title: "Settle", image: "checkmark.circle" },
+ );
+ }
+
+ if (row.snoozeSupported) {
+ const snoozed = effectiveSnoozed(thread, { now: input.now });
+ actions.push(
+ snoozed
+ ? { id: "unsnooze", title: "Wake", image: "bell" }
+ : { id: "snooze", title: "Snooze", image: "clock" },
+ );
+ }
+
+ actions.push(
+ thread.pinnedAt == null
+ ? { id: "pin", title: "Pin", image: "pin" }
+ : { id: "unpin", title: "Unpin", image: "pin.slash" },
+ );
+
+ if (row.prioritySupported) {
+ for (const choice of PHASE_SIDEBAR_PRIORITY_CHOICES) {
+ actions.push({
+ id: `priority:${choice.value}`,
+ title: choice.label,
+ image: "flag",
+ });
+ }
+ }
+
+ if (phaseSidebarCanForceStopAgent(thread.session)) {
+ actions.push({
+ id: "force-stop",
+ title: "Force stop agent",
+ image: "stop.circle",
+ destructive: true,
+ });
+ }
+
+ actions.push({ id: "archive", title: "Archive", image: "archivebox", destructive: true });
+ return actions;
+}
diff --git a/apps/mobile/src/features/phasesidebar/usePhaseSidebarRows.ts b/apps/mobile/src/features/phasesidebar/usePhaseSidebarRows.ts
new file mode 100644
index 000000000000..d32b1a1faa78
--- /dev/null
+++ b/apps/mobile/src/features/phasesidebar/usePhaseSidebarRows.ts
@@ -0,0 +1,82 @@
+// T3-CUSTOM(expbkt3): binds mobile state to the shared sidebar row model.
+//
+// All the derivation lives in client-runtime (`buildPhaseSidebarRows`), so this
+// hook only gathers what mobile already holds — thread shells, projects, server
+// configs, the viewer's identity and per-thread visit timestamps — and hands it
+// over. Keeping it this thin is the point: the web sidebar and this one cannot
+// drift, because they compute nothing themselves.
+import { useAtomValue } from "@effect/atom-react";
+import type { EnvironmentId, UserId } from "@t3tools/contracts";
+import {
+ buildPhaseSidebarRows,
+ type PhaseSidebarPhaseId,
+ type PhaseSidebarRow,
+} from "@t3tools/client-runtime/state/phase-sidebar";
+import { operatorUserIdFromSessionState } from "@t3tools/client-runtime/state/session";
+import { useMemo, useRef } from "react";
+
+import { useProjects, useServerConfigs, useThreadShells } from "../../state/entities";
+import { environmentSession } from "../../state/session";
+import { usePhaseSidebarVisitTimestamps } from "./phaseSidebarVisitStore";
+
+/**
+ * The operator id for one environment, or null.
+ *
+ * BK mobile carries no Clerk key and never signs in, so identity always comes
+ * from the environment session — the same derivation authorization uses.
+ */
+export function usePhaseSidebarViewerUserId(environmentId: EnvironmentId | null): UserId | null {
+ // The atom family is keyed, so a stable placeholder key keeps the hook order
+ // fixed when no environment is in focus rather than minting an atom a render.
+ const sessionState = useAtomValue(
+ environmentSession.sessionStateValueAtom(environmentId ?? EMPTY_ENVIRONMENT_ID),
+ );
+ if (environmentId === null) return null;
+ return operatorUserIdFromSessionState(sessionState);
+}
+
+// Atom families are keyed, so a stable placeholder avoids creating a new atom
+// per render when no environment is selected.
+const EMPTY_ENVIRONMENT_ID = "__phase-sidebar-no-environment__" as EnvironmentId;
+
+/**
+ * Every sidebar row, across every connected environment.
+ *
+ * `viewerEnvironmentId` decides whose ownership facets ("mine", "assigned to
+ * me") the rows carry. Mobile has no single primary environment, so the caller
+ * passes the environment in focus. This mirrors the web sidebar's documented
+ * rough edge: on a thread hosted by a different environment, ownership is
+ * resolved against the focused environment's operator.
+ */
+export function usePhaseSidebarRows(input: {
+ readonly viewerEnvironmentId: EnvironmentId | null;
+}): ReadonlyArray {
+ const threads = useThreadShells();
+ const projects = useProjects();
+ const serverConfigs = useServerConfigs();
+ const visitTimestamps = usePhaseSidebarVisitTimestamps();
+ const currentUserId = usePhaseSidebarViewerUserId(input.viewerEnvironmentId);
+ // Mirrors the web sidebar: this component's own anti-flap memory, not state
+ // the row model owns.
+ const lastKnownPhaseByThreadKey = useRef(new Map());
+
+ return useMemo(
+ () =>
+ buildPhaseSidebarRows({
+ threads,
+ projects,
+ serverConfigs,
+ // Mobile does not aggregate per-thread VCS status yet; the rows simply
+ // carry no change-request badge until it does. Everything else is
+ // independent of it.
+ vcsStatusByThreadKey: EMPTY_VCS_STATUS,
+ lastVisitedAtByThreadKey: visitTimestamps,
+ currentUserId,
+ allEnvironmentShellsLive: true,
+ lastKnownPhaseByThreadKey: lastKnownPhaseByThreadKey.current,
+ }),
+ [currentUserId, projects, serverConfigs, threads, visitTimestamps],
+ );
+}
+
+const EMPTY_VCS_STATUS = new Map();
diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
index 040a82cc8309..1d454829f0eb 100644
--- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx
@@ -36,6 +36,8 @@ import { runtime } from "../../lib/runtime";
import { useThemeColor } from "../../lib/useThemeColor";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
+// T3-CUSTOM(expbkt3): experimental phase-grouped sidebar opt-in.
+import { usePhaseSidebarEnabled } from "../phasesidebar/phaseSidebarEnabled";
import {
type AppUpdateCheckState,
isAppUpdateCheckAvailable,
@@ -138,6 +140,9 @@ function LocalSettingsRouteScreen() {
+ {/* T3-CUSTOM(expbkt3): experimental fork features. */}
+
+
@@ -529,6 +534,9 @@ function ConfiguredSettingsRouteScreen() {
+ {/* T3-CUSTOM(expbkt3): experimental fork features. */}
+
+
@@ -594,6 +602,30 @@ function LegacySettingsSection() {
);
}
+// T3-CUSTOM(expbkt3): BEGIN — experimental fork features.
+function ExperimentsSettingsSection() {
+ const savePreferences = useAtomSet(updateMobilePreferencesAtom);
+ const phaseSidebarEnabled = usePhaseSidebarEnabled();
+
+ return (
+
+
+ savePreferences({ experimentalPhaseSidebarEnabled: value })}
+ />
+
+
+ Groups threads by lifecycle and shows the full row lane — worktree codename, Linear tag,
+ priority and owner. Experimental; turn it off to return to the stock list.
+
+
+ );
+}
+// T3-CUSTOM(expbkt3): END
+
function AppSettingsSection() {
const icon = useThemeColor("--color-icon");
const [updateState, setUpdateState] = useState("idle");
diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
index 0e74e27f8743..81ba1493340b 100644
--- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
+++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
@@ -6,6 +6,8 @@ import {
threadSearchMatchKey,
type EnvironmentThreadSearchMatch,
} from "@t3tools/client-runtime/state/thread-search";
+// T3-CUSTOM(expbkt3): the phase sidebar opens the members sheet directly.
+import { useNavigation } from "@react-navigation/native";
import { LegendList } from "@legendapp/list/react-native";
import type { MenuAction } from "@react-native-menu/menu";
import { useAtomValue } from "@effect/atom-react";
@@ -32,6 +34,28 @@ import { useProjects, useThreadShells } from "../../state/entities";
import { mobilePreferencesAtom } from "../../state/preferences";
import { useThreadSearch } from "../../state/queries";
import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled";
+// T3-CUSTOM(expbkt3): experimental phase-grouped sidebar.
+import { threadEnvironment } from "../../state/threads";
+import { useAtomCommand } from "../../state/use-atom-command";
+import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
+import { PHASE_SIDEBAR_PRIORITY_CHOICES } from "@t3tools/client-runtime/state/phase-sidebar";
+import {
+ DEFAULT_PHASE_SIDEBAR_SORT,
+ EMPTY_PHASE_SIDEBAR_FILTERS,
+ type PhaseSidebarFilters,
+ type PhaseSidebarSortPreferences,
+} from "@t3tools/client-runtime/state/phase-sidebar";
+import { phaseSidebarFiltersActive } from "@t3tools/client-runtime/state/phase-sidebar-tree";
+import { cn } from "../../lib/cn";
+import { PhaseSidebarFilterSheet } from "../phasesidebar/PhaseSidebarFilterSheet";
+import { PhaseSidebarList } from "../phasesidebar/PhaseSidebarList";
+import { PhaseSidebarRateLimits } from "../phasesidebar/PhaseSidebarRateLimits";
+import { usePhaseSidebarEnabled } from "../phasesidebar/phaseSidebarEnabled";
+import {
+ usePhaseSidebarRows,
+ usePhaseSidebarViewerUserId,
+} from "../phasesidebar/usePhaseSidebarRows";
+import { useMarkPhaseSidebarThreadVisited } from "../phasesidebar/phaseSidebarVisitStore";
import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences";
import { environmentServerConfigsAtom } from "../../state/server";
import { usePendingNewTasks } from "../../state/use-pending-new-tasks";
@@ -173,6 +197,40 @@ function ThreadNavigationSidebarPane(
regenerateThreadTitle,
} = useThreadListActions();
const threadListV2Enabled = useThreadListV2Enabled();
+ // T3-CUSTOM(expbkt3): BEGIN — experimental phase sidebar.
+ const phaseSidebarEnabled = usePhaseSidebarEnabled();
+ const phaseSidebarNavigation = useNavigation();
+ // The two row actions useThreadListActions does not cover.
+ const updatePhaseSidebarThreadMetadata = useAtomCommand(
+ threadEnvironment.updateMetadata,
+ "phase sidebar update thread metadata",
+ );
+ const stopPhaseSidebarExecution = useAtomCommand(
+ threadEnvironment.stopExecution,
+ "phase sidebar force stop",
+ );
+ // Ownership facets resolve against the environment in focus; mobile has no
+ // single primary environment, so the selected thread's is the best answer.
+ const phaseSidebarViewerEnvironmentId = useMemo(
+ () =>
+ props.selectedThreadKey === null
+ ? null
+ : (parseScopedThreadKey(props.selectedThreadKey)?.environmentId ?? null),
+ [props.selectedThreadKey],
+ );
+ const phaseSidebarViewerUserId = usePhaseSidebarViewerUserId(phaseSidebarViewerEnvironmentId);
+ const phaseSidebarRows = usePhaseSidebarRows({
+ viewerEnvironmentId: phaseSidebarViewerEnvironmentId,
+ });
+ const markPhaseSidebarThreadVisited = useMarkPhaseSidebarThreadVisited();
+ const [phaseSidebarFilters, setPhaseSidebarFilters] = useState(
+ EMPTY_PHASE_SIDEBAR_FILTERS,
+ );
+ const [phaseSidebarSort, setPhaseSidebarSort] = useState(
+ DEFAULT_PHASE_SIDEBAR_SORT,
+ );
+ const [phaseSidebarFilterOpen, setPhaseSidebarFilterOpen] = useState(false);
+ // T3-CUSTOM(expbkt3): END
const preferencesResult = useAtomValue(mobilePreferencesAtom);
const autoSettleOnMerge =
!AsyncResult.isSuccess(preferencesResult) ||
@@ -756,6 +814,123 @@ function ThreadNavigationSidebarPane(
openSwipeableRef.current = null;
}
}, []);
+ // T3-CUSTOM(expbkt3): BEGIN — phase sidebar row handlers.
+ const handlePhaseSidebarSelect = useCallback(
+ (row: { readonly thread: EnvironmentThreadShell }) => {
+ markPhaseSidebarThreadVisited(`${row.thread.environmentId}:${row.thread.id}`);
+ props.onSelectThread(row.thread);
+ },
+ [markPhaseSidebarThreadVisited, props.onSelectThread],
+ );
+ const handlePhaseSidebarReparent = useCallback(
+ (
+ subject: { readonly thread: EnvironmentThreadShell },
+ parent: { readonly thread: EnvironmentThreadShell } | null,
+ ) => {
+ // Same command web's setThreadParent uses; the drop was already validated
+ // against cycles and the depth limit before it got here.
+ void updatePhaseSidebarThreadMetadata({
+ environmentId: subject.thread.environmentId,
+ input: {
+ threadId: subject.thread.id,
+ parentThreadId: parent === null ? null : parent.thread.id,
+ },
+ });
+ },
+ [updatePhaseSidebarThreadMetadata],
+ );
+
+ const handlePhaseSidebarReorder = useCallback(
+ (
+ subject: { readonly thread: EnvironmentThreadShell },
+ before: { readonly thread: EnvironmentThreadShell },
+ ) => {
+ // `movePinnedThread` owns the correct order-key planning (and the
+ // capability check), but only moves one position at a time. Direction
+ // comes from the pin ORDER — `pinOrderKey` is the sortable key the server
+ // assigns — not from `pinnedAt`, which is when the pin happened and says
+ // nothing about position.
+ //
+ // Known limitation: one drag moves one position, so dragging a pin a long
+ // way needs repeating. Expressing an arbitrary target would mean
+ // duplicating planPinnedMove's fractional-index logic here.
+ const subjectKey = subject.thread.pinOrderKey ?? "";
+ const beforeKey = before.thread.pinOrderKey ?? "";
+ void movePinnedThread(subject.thread, subjectKey > beforeKey ? "up" : "down");
+ },
+ [movePinnedThread],
+ );
+
+ const handlePhaseSidebarRowAction = useCallback(
+ (row: { readonly thread: EnvironmentThreadShell }, actionId: string) => {
+ const thread = row.thread;
+
+ if (actionId.startsWith("priority:")) {
+ const parsed = Number.parseInt(actionId.slice("priority:".length), 10);
+ const priority = PHASE_SIDEBAR_PRIORITY_CHOICES.find(
+ (choice) => choice.value === parsed,
+ )?.value;
+ if (priority === undefined) return;
+ void updatePhaseSidebarThreadMetadata({
+ environmentId: thread.environmentId,
+ input: { threadId: thread.id, priority },
+ });
+ return;
+ }
+
+ switch (actionId) {
+ case "people":
+ phaseSidebarNavigation.navigate("ThreadMembers", {
+ environmentId: thread.environmentId,
+ threadId: thread.id,
+ });
+ return;
+ case "settle":
+ void settleThread(thread);
+ return;
+ case "unsettle":
+ void unsettleThread(thread);
+ return;
+ case "snooze":
+ // One day is the default the row menu offers; the thread screen has
+ // the full picker for anything else.
+ void snoozeThread(thread, new Date(Date.now() + 24 * 60 * 60_000).toISOString());
+ return;
+ case "unsnooze":
+ void unsnoozeThread(thread);
+ return;
+ case "pin":
+ void pinThread(thread);
+ return;
+ case "unpin":
+ void unpinThread(thread);
+ return;
+ case "archive":
+ archiveThread(thread);
+ return;
+ case "force-stop":
+ void stopPhaseSidebarExecution({
+ environmentId: thread.environmentId,
+ input: { threadId: thread.id },
+ });
+ return;
+ }
+ },
+ [
+ archiveThread,
+ phaseSidebarNavigation,
+ pinThread,
+ settleThread,
+ snoozeThread,
+ stopPhaseSidebarExecution,
+ unpinThread,
+ unsettleThread,
+ unsnoozeThread,
+ updatePhaseSidebarThreadMetadata,
+ ],
+ );
+ // T3-CUSTOM(expbkt3): END
+
const handleSelectThread = useCallback(
(thread: EnvironmentThreadShell) => {
props.onSelectThread(thread);
@@ -1200,41 +1375,101 @@ function ThreadNavigationSidebarPane(
unstable_headerRightItems: () => nativeHeaderItems,
}}
/>
-
-
-
- item.type}
- itemsAreEqual={sidebarItemsAreEqual}
- keyExtractor={(item) => item.key}
- renderItem={renderListItem}
- automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED}
- contentInsetAdjustmentBehavior={
- NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"
- }
- contentContainerStyle={[
- styles.threadListContent,
- {
- paddingBottom: Math.max(insets.bottom, 16) + 16,
- paddingTop: 6,
- },
- ]}
- keyboardDismissMode="on-drag"
- keyboardShouldPersistTaps="handled"
- {...scrollGateHandlers}
- recycleItems
- scrollEventThrottle={16}
- showsVerticalScrollIndicator={false}
- style={styles.threadList}
- ListEmptyComponent={listEmpty}
- />
-
-
-
+ {/* T3-CUSTOM(expbkt3): BEGIN — experimental phase-grouped sidebar. It
+ replaces the whole list rather than reshaping it, so the stock list
+ keeps its exact behaviour when the flag is off. */}
+ {phaseSidebarEnabled ? (
+
+ {phaseSidebarFilterOpen ? (
+
+
+
+ ) : null}
+
+
+
+
+ Lifecycle
+
+ setPhaseSidebarFilterOpen((open) => !open)}
+ >
+
+ Filter
+
+
+
+ }
+ activeThreadKey={props.selectedThreadKey}
+ onReorderRow={handlePhaseSidebarReorder}
+ onReparentRow={handlePhaseSidebarReparent}
+ onRowAction={handlePhaseSidebarRowAction}
+ onSelectRow={handlePhaseSidebarSelect}
+ rows={phaseSidebarRows}
+ viewerUserId={phaseSidebarViewerUserId}
+ />
+
+ ) : (
+ /* T3-CUSTOM(expbkt3): END */
+
+
+
+ item.type}
+ itemsAreEqual={sidebarItemsAreEqual}
+ keyExtractor={(item) => item.key}
+ renderItem={renderListItem}
+ automaticallyAdjustsScrollIndicatorInsets={NATIVE_LIQUID_GLASS_SUPPORTED}
+ contentInsetAdjustmentBehavior={
+ NATIVE_LIQUID_GLASS_SUPPORTED ? "automatic" : "never"
+ }
+ contentContainerStyle={[
+ styles.threadListContent,
+ {
+ paddingBottom: Math.max(insets.bottom, 16) + 16,
+ paddingTop: 6,
+ },
+ ]}
+ keyboardDismissMode="on-drag"
+ keyboardShouldPersistTaps="handled"
+ {...scrollGateHandlers}
+ recycleItems
+ scrollEventThrottle={16}
+ showsVerticalScrollIndicator={false}
+ style={styles.threadList}
+ ListEmptyComponent={listEmpty}
+ />
+
+
+
+ )}
>
);
}
diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts
index 2234e0474372..0b913713329e 100644
--- a/apps/mobile/src/persistence/mobile-preferences.ts
+++ b/apps/mobile/src/persistence/mobile-preferences.ts
@@ -48,6 +48,18 @@ export interface Preferences {
readonly threadListV2SnoozedShelfExpanded?: boolean;
// T3-CUSTOM(expbkt3): remember the last-used source-control profile per environment.
readonly lastSourceControlProfileByEnvironment?: Readonly>;
+ /**
+ * T3-CUSTOM(expbkt3): opt into the experimental phase-grouped sidebar, the
+ * mobile counterpart of web's control centre. Off unless explicitly enabled,
+ * so a device keeps the stock thread list until someone asks for this.
+ */
+ readonly experimentalPhaseSidebarEnabled?: boolean;
+ /**
+ * T3-CUSTOM(expbkt3): last-visited time per scoped thread key, backing the
+ * phase sidebar's unread dot. Pruned to a cap on write, so this stays a small
+ * bounded map rather than growing with every thread ever opened.
+ */
+ readonly phaseSidebarVisitedAt?: Readonly>;
}
export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()(
@@ -105,6 +117,9 @@ function sanitizePreferences(parsed: Preferences): Preferences {
projectGroupingMode?: SidebarProjectGroupingMode;
autoSettleOnMerge?: boolean;
legacyThreadListEnabled?: boolean;
+ // T3-CUSTOM(expbkt3): experimental phase sidebar opt-in and its visit map.
+ experimentalPhaseSidebarEnabled?: boolean;
+ phaseSidebarVisitedAt?: Record;
planModeEnabled?: boolean;
threadListV2SettledShelfExpanded?: boolean;
threadListV2SnoozedShelfExpanded?: boolean;
@@ -177,6 +192,17 @@ function sanitizePreferences(parsed: Preferences): Preferences {
if (typeof parsed.legacyThreadListEnabled === "boolean") {
preferences.legacyThreadListEnabled = parsed.legacyThreadListEnabled;
}
+ // T3-CUSTOM(expbkt3): experimental phase-grouped sidebar opt-in.
+ if (typeof parsed.experimentalPhaseSidebarEnabled === "boolean") {
+ preferences.experimentalPhaseSidebarEnabled = parsed.experimentalPhaseSidebarEnabled;
+ }
+ if (parsed.phaseSidebarVisitedAt !== null && typeof parsed.phaseSidebarVisitedAt === "object") {
+ const visited: Record = {};
+ for (const [key, value] of Object.entries(parsed.phaseSidebarVisitedAt)) {
+ if (typeof value === "string" && value.length > 0) visited[key] = value;
+ }
+ preferences.phaseSidebarVisitedAt = visited;
+ }
if (typeof parsed.planModeEnabled === "boolean") {
preferences.planModeEnabled = parsed.planModeEnabled;
}
diff --git a/apps/web/src/components/PhaseGroupedSidebar.tsx b/apps/web/src/components/PhaseGroupedSidebar.tsx
index dc49f9e31b70..b362faa73313 100644
--- a/apps/web/src/components/PhaseGroupedSidebar.tsx
+++ b/apps/web/src/components/PhaseGroupedSidebar.tsx
@@ -155,6 +155,7 @@ import {
resolvePhaseSidebarChangeRequestBadge,
resolvePhaseSidebarDisplayPhase,
resolvePhaseSidebarPhase,
+ buildPhaseSidebarRows,
resolvePhaseSidebarLinearIssue,
resolvePhaseSidebarMattermostLink,
resolvePhaseSidebarProviderCode,
@@ -2059,74 +2060,28 @@ export function PhaseGroupedSidebar() {
},
[],
);
+ // T3-CUSTOM(expbkt3): row assembly lives in client-runtime so the mobile
+ // phase sidebar builds identical rows. Only the last-known-phase ref stays
+ // here, because it is this component's own anti-flap state.
const allRows = useMemo>(
() =>
- threads.map((thread) => {
- const project = projectByKey.get(
- scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
- );
- const repositoryKey = project
- ? derivePhaseSidebarRepositoryKey(project)
- : scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId));
- const serverConfig = serverConfigs.get(thread.environmentId);
- const instanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
- const provider = serverConfig?.providers.find(
- (candidate) => candidate.instanceId === instanceId,
- );
- const providerKind = String(provider?.driver ?? instanceId);
- const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
- const vcsStatus = vcsStatusByThreadKey.get(threadKey);
- const currentPhase = resolvePhaseSidebarPhase(thread, vcsStatus);
- const isUnreadCompletion = hasUnseenCompletion({
- ...thread,
- lastVisitedAt: lastVisitedAtByThreadKey[threadKey],
- });
- return {
- thread,
- phaseId: resolvePhaseSidebarDisplayPhase(
- currentPhase,
- allEnvironmentShellsLive
- ? null
- : (lastKnownPhaseByThreadKeyRef.current.get(threadKey) ?? null),
- ),
- repositoryKey,
- repositoryLabel:
- project?.title ?? repositoryLabels.get(repositoryKey) ?? "Unknown repository",
- providerKind,
- providerName: provider?.displayName ?? thread.session?.providerName ?? String(instanceId),
- isAssignedToMe: currentUserId !== null && isThreadAssignedToUser(thread, currentUserId),
- // T3-CUSTOM(expbkt3): BEGIN — ownership and co-participant facets.
- isOwnedByMe: currentUserId !== null && thread.ownerUserId === currentUserId,
- participantUserIds: phaseSidebarThreadParticipantIds(thread),
- // T3-CUSTOM(expbkt3): END
- attentionPriority: resolvePhaseSidebarAttentionPriority(thread, vcsStatus),
- isUnreadCompletion,
- // T3-CUSTOM(expbkt3): BEGIN — lifecycle parking inputs.
- settlementSupported: serverConfig?.environment.capabilities.threadSettlement === true,
- snoozeSupported: serverConfig?.environment.capabilities.threadSnooze === true,
- prioritySupported: serverConfig?.environment.capabilities.threadPriority === true,
- linearIssueSupported: serverConfig?.environment.capabilities.threadLinearIssue === true,
- // T3-CUSTOM(expbkt3): durable Mattermost conversation link.
- mattermostLinkSupported:
- serverConfig?.environment.capabilities.threadMattermostLink === true,
- titleRegenerationSupported:
- serverConfig?.environment.capabilities.threadTitleRegeneration === true,
- threadBootstrapSupported:
- serverConfig?.environment.capabilities.durableThreadBootstrap === true,
- changeRequestState: vcsStatus?.pr?.state ?? null,
- changeRequestUpdatedAt: vcsStatus?.pr?.updatedAt ?? null,
- // T3-CUSTOM(expbkt3): END
- };
+ buildPhaseSidebarRows({
+ threads,
+ projects,
+ serverConfigs,
+ vcsStatusByThreadKey,
+ lastVisitedAtByThreadKey,
+ currentUserId,
+ allEnvironmentShellsLive,
+ lastKnownPhaseByThreadKey: lastKnownPhaseByThreadKeyRef.current,
}),
[
- projectByKey,
- repositoryLabels,
+ projects,
serverConfigs,
threads,
allEnvironmentShellsLive,
currentUserId,
lastVisitedAtByThreadKey,
- primaryEnvironmentId,
vcsStatusByThreadKey,
],
);
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
index 7bb157407661..0581ce307c32 100644
--- a/apps/web/src/components/Sidebar.logic.ts
+++ b/apps/web/src/components/Sidebar.logic.ts
@@ -1,3 +1,5 @@
+// T3-CUSTOM(expbkt3): shared with the mobile phase sidebar.
+import { hasUnseenCompletion } from "@t3tools/client-runtime/state/phase-sidebar";
import * as React from "react";
import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable";
import type { ContextMenuItem } from "@t3tools/contracts";
@@ -280,16 +282,10 @@ export function useThreadJumpHintVisibility(): {
};
}
-export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
- if (!thread.latestTurn?.completedAt) return false;
- const completedAt = Date.parse(thread.latestTurn.completedAt);
- if (Number.isNaN(completedAt)) return false;
- if (!thread.lastVisitedAt) return false;
-
- const lastVisitedAt = Date.parse(thread.lastVisitedAt);
- if (Number.isNaN(lastVisitedAt)) return true;
- return completedAt > lastVisitedAt;
-}
+// T3-CUSTOM(expbkt3): moved to client-runtime so the mobile phase sidebar can
+// share buildPhaseSidebarRows. Re-exported here so existing imports still work,
+// and imported above because this module calls it too.
+export { hasUnseenCompletion };
export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean {
if (target === null) return true;
diff --git a/apps/web/src/fork/environmentOperatorIdentity.ts b/apps/web/src/fork/environmentOperatorIdentity.ts
index 6c6ebafd3bde..48978d8a2740 100644
--- a/apps/web/src/fork/environmentOperatorIdentity.ts
+++ b/apps/web/src/fork/environmentOperatorIdentity.ts
@@ -28,6 +28,8 @@ import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
+import { operatorUserIdFromSessionState } from "@t3tools/client-runtime/state/session";
+
import { fetchSessionState } from "../environments/primary/auth";
/**
@@ -35,14 +37,7 @@ import { fetchSessionState } from "../environments/primary/auth";
* has none — an unauthenticated gate state, or a single-user/local environment
* where the subject is not a Clerk operator.
*/
-export function operatorUserIdFromSessionState(
- sessionState: AuthSessionState | null,
-): UserId | null {
- if (sessionState === null || !sessionState.authenticated) {
- return null;
- }
- return sessionState.userId ?? null;
-}
+export { operatorUserIdFromSessionState };
/**
* Deliberately its own atom rather than a re-export of the primary session
diff --git a/packages/client-runtime/src/state/phaseSidebar.test.ts b/packages/client-runtime/src/state/phaseSidebar.test.ts
index f090b7fb830f..8a9feb1f9f68 100644
--- a/packages/client-runtime/src/state/phaseSidebar.test.ts
+++ b/packages/client-runtime/src/state/phaseSidebar.test.ts
@@ -10,6 +10,8 @@ import { describe, expect, it } from "vite-plus/test";
import {
buildPhaseSidebarGroups,
+ buildPhaseSidebarRows,
+ hasUnseenCompletion,
collectDescendantThreadIds,
DEFAULT_PHASE_SIDEBAR_SORT,
EMPTY_PHASE_SIDEBAR_FILTERS,
@@ -24,7 +26,7 @@ import {
summarizeSidebarSessions,
type PhaseSidebarRow,
} from "./phaseSidebar.ts";
-import type { EnvironmentThreadShell } from "./shell.ts";
+import type { EnvironmentProject, EnvironmentThreadShell } from "./shell.ts";
// The bulk of this module's behaviour is covered by the web suite that has
// exercised it since it lived under apps/web (it still imports it, through the
@@ -325,3 +327,141 @@ describe("unread tracking", () => {
).toBe(false);
});
});
+
+// T3-CUSTOM(expbkt3): row assembly moved out of apps/web so the mobile phase
+// sidebar builds identical rows. These are the fork-owned proof that the move
+// preserved behaviour, and that mobile can rely on it after an upstream merge.
+describe("buildPhaseSidebarRows", () => {
+ const project = {
+ id: projectId,
+ environmentId,
+ title: "beknown-services",
+ workspaceRoot: "/home/dev/beknown-services",
+ } as EnvironmentProject;
+
+ const buildOne = (
+ thread: EnvironmentThreadShell,
+ input: Partial[0]> = {},
+ ) =>
+ buildPhaseSidebarRows({
+ threads: [thread],
+ projects: [project],
+ serverConfigs: new Map(),
+ vcsStatusByThreadKey: new Map(),
+ lastVisitedAtByThreadKey: {},
+ currentUserId: null,
+ allEnvironmentShellsLive: true,
+ lastKnownPhaseByThreadKey: null,
+ ...input,
+ })[0]!;
+
+ it("labels the row with its project title", () => {
+ expect(buildOne(makeThread()).repositoryLabel).toBe("beknown-services");
+ });
+
+ it("falls back to a readable label when the project is unknown", () => {
+ const row = buildOne(makeThread(), { projects: [] });
+ expect(row.repositoryLabel).toBe("Unknown repository");
+ });
+
+ it("reports every capability as unsupported when no server config is known", () => {
+ const row = buildOne(makeThread());
+ expect(row.settlementSupported).toBe(false);
+ expect(row.snoozeSupported).toBe(false);
+ expect(row.prioritySupported).toBe(false);
+ expect(row.linearIssueSupported).toBe(false);
+ expect(row.mattermostLinkSupported).toBe(false);
+ });
+
+ it("reads capabilities from the thread's own environment config", () => {
+ const row = buildOne(makeThread(), {
+ serverConfigs: new Map([
+ [
+ environmentId,
+ {
+ environment: {
+ capabilities: { threadSettlement: true, threadMattermostLink: true },
+ },
+ providers: [],
+ },
+ ],
+ ]) as never,
+ });
+ expect(row.settlementSupported).toBe(true);
+ expect(row.mattermostLinkSupported).toBe(true);
+ expect(row.snoozeSupported).toBe(false);
+ });
+
+ it("marks ownership and assignment against the current user", () => {
+ const mine = makeThread({ ownerUserId: "user-1" as never });
+ const row = buildOne(mine, { currentUserId: "user-1" as never });
+ expect(row.isOwnedByMe).toBe(true);
+ expect(row.isAssignedToMe).toBe(true);
+ });
+
+ it("does not claim ownership when nobody is signed in", () => {
+ const row = buildOne(makeThread({ ownerUserId: "user-1" as never }));
+ expect(row.isOwnedByMe).toBe(false);
+ expect(row.isAssignedToMe).toBe(false);
+ });
+
+ // `resolvePhaseSidebarDisplayPhase` currently ignores the previous phase (its
+ // parameter is vestigial), so the display phase always follows the live one.
+ // The builder still threads the map through, so if that anti-flap behaviour is
+ // ever restored both clients get it at once.
+ it("follows the live phase regardless of the last known one", () => {
+ const thread = makeThread();
+ const threadKey = `${environmentId}:${thread.id}`;
+ for (const allEnvironmentShellsLive of [true, false]) {
+ const row = buildOne(thread, {
+ allEnvironmentShellsLive,
+ lastKnownPhaseByThreadKey: new Map([[threadKey, "implementing" as const]]),
+ });
+ expect(row.phaseId).toBe(resolvePhaseSidebarPhase(thread));
+ }
+ });
+
+ it("builds one row per thread", () => {
+ const rows = buildPhaseSidebarRows({
+ threads: [makeThread({ id: ThreadId.make("a") }), makeThread({ id: ThreadId.make("b") })],
+ projects: [project],
+ serverConfigs: new Map(),
+ vcsStatusByThreadKey: new Map(),
+ lastVisitedAtByThreadKey: {},
+ currentUserId: null,
+ allEnvironmentShellsLive: true,
+ lastKnownPhaseByThreadKey: null,
+ });
+ expect(rows.map((row) => row.thread.id)).toEqual(["a", "b"]);
+ });
+});
+
+describe("hasUnseenCompletion", () => {
+ it("is false when the turn never completed", () => {
+ expect(hasUnseenCompletion({ latestTurn: null, lastVisitedAt: now })).toBe(false);
+ });
+
+ it("is false when the thread was never visited", () => {
+ expect(
+ hasUnseenCompletion({ latestTurn: { completedAt: now } as never, lastVisitedAt: undefined }),
+ ).toBe(false);
+ });
+
+ it("is true when the turn finished after the last visit", () => {
+ expect(
+ hasUnseenCompletion({
+ latestTurn: { completedAt: "2026-08-31T12:00:00.000Z" } as never,
+ lastVisitedAt: "2026-08-31T11:00:00.000Z",
+ }),
+ ).toBe(true);
+ });
+
+ it("treats an unparseable visit timestamp as unread", () => {
+ expect(
+ hasUnseenCompletion({
+ latestTurn: { completedAt: "2026-08-31T12:00:00.000Z" } as never,
+ lastVisitedAt: "not-a-date",
+ }),
+ ).toBe(true);
+ });
+});
diff --git a/packages/client-runtime/src/state/phaseSidebar.ts b/packages/client-runtime/src/state/phaseSidebar.ts
index c5c28342b8e3..faf4445cbf02 100644
--- a/packages/client-runtime/src/state/phaseSidebar.ts
+++ b/packages/client-runtime/src/state/phaseSidebar.ts
@@ -17,7 +17,7 @@
// not ship the ES2023 change-array-by-copy methods. Sort a copy with `.sort()`;
// never reach for `.toSorted()`. phaseSidebar.test.ts asserts this by deleting
// the method from Array.prototype.
-import type { UserId, VcsStatusResult } from "@t3tools/contracts";
+import type { ServerConfig, UserId, VcsStatusResult } from "@t3tools/contracts";
import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";
import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl";
// T3-CUSTOM(expbkt3): memorable worktree codenames.
@@ -27,6 +27,12 @@ import {
worktreeCodenameToneIndex,
} from "@t3tools/shared/worktreeCodename";
+import {
+ scopeProjectRef,
+ scopeThreadRef,
+ scopedProjectKey,
+ scopedThreadKey,
+} from "../environment/scoped.ts";
import { deriveLogicalProjectKey } from "./projectGrouping.ts";
import type { EnvironmentProject, EnvironmentThreadShell } from "./shell.ts";
import {
@@ -1336,6 +1342,31 @@ export function resolveThreadVisitTimestamp(input: ThreadVisitTimestampInput): s
* the last visit this device recorded. An unvisited thread is NOT unread —
* otherwise a fresh install marks the entire list.
*/
+/**
+ * T3-CUSTOM(expbkt3): Whether a finished turn has not been looked at yet.
+ *
+ * Moved here from apps/web/src/components/Sidebar.logic.ts (which re-exports it)
+ * so `buildPhaseSidebarRows` can run on both clients. Deliberately NOT unified
+ * with `isThreadUnread` below: this one treats an unparseable visit timestamp as
+ * unread, and that difference is load-bearing for the row's dot.
+ */
+export function hasUnseenCompletion(
+ thread: Pick & {
+ readonly lastVisitedAt?: string | null | undefined;
+ /** Callers pass whole thread shells; extra facts are simply unread here. */
+ readonly [extra: string]: unknown;
+ },
+): boolean {
+ if (!thread.latestTurn?.completedAt) return false;
+ const completedAt = Date.parse(thread.latestTurn.completedAt);
+ if (Number.isNaN(completedAt)) return false;
+ if (!thread.lastVisitedAt) return false;
+
+ const lastVisitedAt = Date.parse(thread.lastVisitedAt);
+ if (Number.isNaN(lastVisitedAt)) return true;
+ return completedAt > lastVisitedAt;
+}
+
export function isThreadUnread(input: {
readonly threadUpdatedAt: string;
readonly latestTurnCompletedAt: string | null | undefined;
@@ -1348,3 +1379,98 @@ export function isThreadUnread(input: {
if (Number.isNaN(activityAtMs)) return false;
return activityAtMs > lastVisitedAtMs;
}
+
+/**
+ * T3-CUSTOM(expbkt3): Everything needed to turn raw thread shells into rows.
+ *
+ * `projects` and `serverConfigs` are the caller's own maps rather than derived
+ * state, because both clients already hold them; the repository key and label
+ * tables are derived here so neither client has to reproduce that stitching.
+ */
+export interface BuildPhaseSidebarRowsInput {
+ readonly threads: ReadonlyArray;
+ readonly projects: ReadonlyArray;
+ readonly serverConfigs: ReadonlyMap;
+ /** Keyed by `scopedThreadKey`. Absent entries simply have no VCS facts yet. */
+ readonly vcsStatusByThreadKey: ReadonlyMap;
+ /** Keyed by `scopedThreadKey`. */
+ readonly lastVisitedAtByThreadKey: Readonly>;
+ readonly currentUserId: UserId | null;
+ /**
+ * When false, a row falls back to its last known phase rather than flapping
+ * to a wrong one while an environment's shells are still arriving.
+ */
+ readonly allEnvironmentShellsLive: boolean;
+ /** Keyed by `scopedThreadKey`. Null when the caller keeps no history. */
+ readonly lastKnownPhaseByThreadKey: ReadonlyMap | null;
+}
+
+/**
+ * Builds the sidebar's row model. Pure, so both the web sidebar and the mobile
+ * phase sidebar render the same lifecycle, badges and ownership facts.
+ */
+export function buildPhaseSidebarRows(
+ input: BuildPhaseSidebarRowsInput,
+): ReadonlyArray {
+ const projectByKey = new Map(
+ input.projects.map((project) => [
+ scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
+ project,
+ ]),
+ );
+ const repositoryLabels = new Map(
+ buildPhaseSidebarRepositoryOptions(input.projects).map((option) => [option.key, option.label]),
+ );
+
+ return input.threads.map((thread) => {
+ const project = projectByKey.get(
+ scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)),
+ );
+ const repositoryKey = project
+ ? derivePhaseSidebarRepositoryKey(project)
+ : scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId));
+ const serverConfig = input.serverConfigs.get(thread.environmentId);
+ const instanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
+ const provider = serverConfig?.providers.find(
+ (candidate) => candidate.instanceId === instanceId,
+ );
+ const providerKind = String(provider?.driver ?? instanceId);
+ const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
+ const vcsStatus = input.vcsStatusByThreadKey.get(threadKey);
+ const currentPhase = resolvePhaseSidebarPhase(thread, vcsStatus);
+ const capabilities = serverConfig?.environment.capabilities;
+
+ return {
+ thread,
+ phaseId: resolvePhaseSidebarDisplayPhase(
+ currentPhase,
+ input.allEnvironmentShellsLive
+ ? null
+ : (input.lastKnownPhaseByThreadKey?.get(threadKey) ?? null),
+ ),
+ repositoryKey,
+ repositoryLabel:
+ project?.title ?? repositoryLabels.get(repositoryKey) ?? "Unknown repository",
+ providerKind,
+ providerName: provider?.displayName ?? thread.session?.providerName ?? String(instanceId),
+ isAssignedToMe:
+ input.currentUserId !== null && isThreadAssignedToUser(thread, input.currentUserId),
+ isOwnedByMe: input.currentUserId !== null && thread.ownerUserId === input.currentUserId,
+ participantUserIds: phaseSidebarThreadParticipantIds(thread),
+ attentionPriority: resolvePhaseSidebarAttentionPriority(thread, vcsStatus),
+ isUnreadCompletion: hasUnseenCompletion({
+ ...thread,
+ lastVisitedAt: input.lastVisitedAtByThreadKey[threadKey],
+ }),
+ settlementSupported: capabilities?.threadSettlement === true,
+ snoozeSupported: capabilities?.threadSnooze === true,
+ prioritySupported: capabilities?.threadPriority === true,
+ linearIssueSupported: capabilities?.threadLinearIssue === true,
+ mattermostLinkSupported: capabilities?.threadMattermostLink === true,
+ titleRegenerationSupported: capabilities?.threadTitleRegeneration === true,
+ threadBootstrapSupported: capabilities?.durableThreadBootstrap === true,
+ changeRequestState: vcsStatus?.pr?.state ?? null,
+ changeRequestUpdatedAt: vcsStatus?.pr?.updatedAt ?? null,
+ };
+ });
+}
diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts
index 31fd297da3f0..04349c56ebac 100644
--- a/packages/client-runtime/src/state/session.ts
+++ b/packages/client-runtime/src/state/session.ts
@@ -1,4 +1,5 @@
-import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts";
+// T3-CUSTOM(expbkt3): UserId for operatorUserIdFromSessionState below.
+import type { AuthSessionState, EnvironmentId, ServerConfig, UserId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Stream from "effect/Stream";
@@ -61,6 +62,64 @@ export const fetchEnvironmentSessionState = Effect.fn(
);
});
+// T3-CUSTOM(expbkt3): BEGIN
+/**
+ * The org user directory for one environment, via `/api/orchestration/users`.
+ *
+ * These are `UserId`s — org-level people who can own or be tagged on a thread —
+ * and deliberately not the `EnvironmentUserId`s that `users.list` returns; those
+ * are per-environment accounts and a different id space entirely. Lives here
+ * because this module already carries the prepared-connection and auth-header
+ * plumbing an environment HTTP call needs.
+ */
+export const fetchOrchestrationUsers = Effect.fn("clientRuntime.state.fetchOrchestrationUsers")(
+ function* (input: {
+ readonly prepared: PreparedConnection;
+ readonly signer: Option.Option;
+ readonly timeoutMs?: number;
+ }) {
+ const requestUrl = environmentEndpointUrl(
+ input.prepared.httpBaseUrl,
+ "/api/orchestration/users",
+ );
+ const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl);
+ const headers = yield* buildEnvironmentAuthHeaders(
+ input.prepared.httpAuthorization,
+ "GET",
+ requestUrl,
+ input.signer,
+ );
+ return yield* executeEnvironmentHttpRequest(
+ requestUrl,
+ input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS,
+ withEnvironmentCredentials(
+ input.prepared.httpAuthorization,
+ client.orchestration.users({ headers }),
+ ),
+ );
+ },
+);
+// T3-CUSTOM(expbkt3): END
+
+// T3-CUSTOM(expbkt3): BEGIN
+/**
+ * The operator id an environment session reports, or null.
+ *
+ * Null for an unauthenticated gate state, and for a single-user or local
+ * environment whose subject is not an operator. Lives here rather than in a
+ * client so web and mobile answer "who am I" identically; web's
+ * fork/environmentOperatorIdentity re-exports it.
+ */
+export function operatorUserIdFromSessionState(
+ sessionState: AuthSessionState | null,
+): UserId | null {
+ if (sessionState === null || !sessionState.authenticated) {
+ return null;
+ }
+ return sessionState.userId ?? null;
+}
+// T3-CUSTOM(expbkt3): END
+
export function createEnvironmentSessionAtoms(
runtime: Atom.AtomRuntime,
) {
@@ -151,6 +210,27 @@ export function createEnvironmentSessionAtoms(
).pipe(Atom.withLabel(`environment-session-state-value:${environmentId}`)),
);
+ // T3-CUSTOM(expbkt3): BEGIN — org user directory for thread tagging.
+ const orchestrationUsersAtom = Atom.family((environmentId: EnvironmentId) =>
+ runtime
+ .atom((get) => {
+ const prepared = Option.getOrNull(get(preparedConnectionValueAtom(environmentId)));
+ if (prepared === null) {
+ return Effect.never;
+ }
+ return Effect.gen(function* () {
+ const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
+ return yield* fetchOrchestrationUsers({ prepared, signer });
+ });
+ })
+ .pipe(
+ Atom.swr({ staleTime: 60_000, revalidateOnMount: true }),
+ Atom.setIdleTTL(5 * 60_000),
+ Atom.withLabel(`environment-orchestration-users:${environmentId}`),
+ ),
+ );
+ // T3-CUSTOM(expbkt3): END
+
return {
initialConfigAtom,
initialConfigValueAtom,
@@ -158,5 +238,7 @@ export function createEnvironmentSessionAtoms(
preparedConnectionValueAtom,
sessionStateAtom,
sessionStateValueAtom,
+ // T3-CUSTOM(expbkt3): org user directory.
+ orchestrationUsersAtom,
};
}