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, }; }