From 9fd9552764507b07776c7527c07734f670020bfd Mon Sep 17 00:00:00 2001 From: tusharbhardwaj-bk Date: Mon, 31 Aug 2026 18:54:24 +0000 Subject: [PATCH 1/6] refactor(client-runtime): share sidebar row assembly with mobile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a768835c4 moved the phase-sidebar logic into client-runtime so mobile could use it, but left row assembly behind: a ~95-line useMemo inside the 3,356-line PhaseGroupedSidebar.tsx. Mobile therefore still could not build a row, which is why it references phaseSidebar in zero files. Extracts it as `buildPhaseSidebarRows`, taking threads, projects, server configs, VCS status and visit timestamps as plain data. The web sidebar now calls it and keeps only its own anti-flap ref. `hasUnseenCompletion` moves across too, since the builder needs it; Sidebar.logic.ts re-exports it so every existing web import is unchanged. It is deliberately not merged with the neighbouring `isThreadUnread` — that one treats an unparseable visit timestamp as read, and the difference drives the row's unread dot. PhaseGroupedSidebar.tsx is fork-owned (absent from upstream/main), so this refactor costs nothing at merge time and removes fork lines from a file we would otherwise keep reconciling. Noted while testing: `resolvePhaseSidebarDisplayPhase` ignores its previous phase argument, so the anti-flap fallback it implies does not currently happen. Left as is rather than changing web behaviour; the builder threads the map through so restoring it would serve both clients. Verified: web's 230 sidebar tests pass untouched, 25 in client-runtime including 10 new fork-owned ones for the builder, and `vp run typecheck` is clean in both packages. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/PhaseGroupedSidebar.tsx | 73 ++------- apps/web/src/components/Sidebar.logic.ts | 16 +- .../src/state/phaseSidebar.test.ts | 142 +++++++++++++++++- .../client-runtime/src/state/phaseSidebar.ts | 128 +++++++++++++++- 4 files changed, 288 insertions(+), 71 deletions(-) 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/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, + }; + }); +} From 198e4932362ef98509c14a69a15effc75b063010 Mon Sep 17 00:00:00 2001 From: tusharbhardwaj-bk Date: Mon, 31 Aug 2026 19:09:55 +0000 Subject: [PATCH 2/6] feat(mobile): phase-sidebar row model, tone table and list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of the mobile experimental sidebar: everything that decides what a row says, and the list that renders it. Not wired into the app yet, so this changes no behaviour. - `phaseSidebarPreferences.ts` holds the pure opt-in resolver and visit-map pruning. Split from the hooks on purpose: anything importing the preferences atom transitively imports react-native, which the unit test bundler cannot parse (Flow). This mirrors the existing threadListV2.ts / hook split, which exists for the same reason. - `usePhaseSidebarRows` binds mobile's thread shells, projects and server configs to the shared `buildPhaseSidebarRows`, so mobile computes nothing itself and cannot drift from web. - `phaseSidebarRowTone.ts` carries mobile's literal class strings. These are duplicated from web deliberately: Tailwind scans apps/web and uniwind scans apps/mobile, so a class string produced in client-runtime would not be generated for either build. Only the literals are duplicated; every decision is shared. - `PhaseSidebarRowView` renders the metadata lane in web's order — repository, worktree codename, Linear tag, Mattermost mark, priority, owner, provider, relative time, unread dot, subtree expander. - `PhaseSidebarList` flattens the tree into one array before rendering, because a nested render tree of hundreds of rows drops frames on scroll. `operatorUserIdFromSessionState` moves into client-runtime so both clients answer "who am I" identically; BK mobile carries no Clerk key, so identity always comes from the environment session. Web's fork module re-exports it. Two things deliberately absent: the PR badge, because mobile does not aggregate per-thread VCS status and rendering a permanently empty badge would be a lie; and any drag affordance, which is the next commit. Verified: 7 fork-owned tests, `vp run typecheck` clean in mobile, web and client-runtime, lint and fmt clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../phasesidebar/PhaseSidebarList.tsx | 162 ++++++++++++++++ .../phasesidebar/PhaseSidebarRowView.tsx | 174 ++++++++++++++++++ .../phasesidebar/phaseSidebarEnabled.ts | 19 ++ .../phaseSidebarPreferences.test.ts | 47 +++++ .../phasesidebar/phaseSidebarPreferences.ts | 53 ++++++ .../phasesidebar/phaseSidebarRowTone.ts | 72 ++++++++ .../phasesidebar/phaseSidebarVisitStore.ts | 38 ++++ .../phasesidebar/usePhaseSidebarRows.ts | 82 +++++++++ .../src/persistence/mobile-preferences.ts | 25 +++ .../src/fork/environmentOperatorIdentity.ts | 11 +- packages/client-runtime/src/state/session.ts | 19 +- 11 files changed, 693 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/src/features/phasesidebar/PhaseSidebarList.tsx create mode 100644 apps/mobile/src/features/phasesidebar/PhaseSidebarRowView.tsx create mode 100644 apps/mobile/src/features/phasesidebar/phaseSidebarEnabled.ts create mode 100644 apps/mobile/src/features/phasesidebar/phaseSidebarPreferences.test.ts create mode 100644 apps/mobile/src/features/phasesidebar/phaseSidebarPreferences.ts create mode 100644 apps/mobile/src/features/phasesidebar/phaseSidebarRowTone.ts create mode 100644 apps/mobile/src/features/phasesidebar/phaseSidebarVisitStore.ts create mode 100644 apps/mobile/src/features/phasesidebar/usePhaseSidebarRows.ts diff --git a/apps/mobile/src/features/phasesidebar/PhaseSidebarList.tsx b/apps/mobile/src/features/phasesidebar/PhaseSidebarList.tsx new file mode 100644 index 000000000000..2322bea81cfc --- /dev/null +++ b/apps/mobile/src/features/phasesidebar/PhaseSidebarList.tsx @@ -0,0 +1,162 @@ +// 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 { useCallback, useMemo, useState } from "react"; +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"; + +/** 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; + readonly onLongPressRow: (row: 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 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; + }); + }, []); + + return ( + item.key} + renderItem={({ item }) => + item.kind === "section" ? ( + + + {item.label} + + + {item.helperText} + + {item.count} + + ) : ( + + ) + } + windowSize={11} + /> + ); +} diff --git a/apps/mobile/src/features/phasesidebar/PhaseSidebarRowView.tsx b/apps/mobile/src/features/phasesidebar/PhaseSidebarRowView.tsx new file mode 100644 index 000000000000..5aedc3578e09 --- /dev/null +++ b/apps/mobile/src/features/phasesidebar/PhaseSidebarRowView.tsx @@ -0,0 +1,174 @@ +// 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 { memo, useCallback } from "react"; +import { Pressable, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +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; + readonly onLongPress: (row: PhaseSidebarRow) => void; + 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 handleLongPress = useCallback(() => props.onLongPress(row), [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} + + + + + ); +}); 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/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/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 2234e0474372..b07214bcd803 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,8 @@ function sanitizePreferences(parsed: Preferences): Preferences { projectGroupingMode?: SidebarProjectGroupingMode; autoSettleOnMerge?: boolean; legacyThreadListEnabled?: boolean; + experimentalPhaseSidebarEnabled?: boolean; + phaseSidebarVisitedAt?: Record; planModeEnabled?: boolean; threadListV2SettledShelfExpanded?: boolean; threadListV2SnoozedShelfExpanded?: boolean; @@ -177,6 +191,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/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/session.ts b/packages/client-runtime/src/state/session.ts index 31fd297da3f0..9210c2c6cb88 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -1,4 +1,4 @@ -import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts"; +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 +61,23 @@ export const fetchEnvironmentSessionState = Effect.fn( ); }); +/** + * T3-CUSTOM(expbkt3): 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; +} + export function createEnvironmentSessionAtoms( runtime: Atom.AtomRuntime, ) { From 6e60f5dd97fe5909cb5b45818c9f1f9568a10ab3 Mon Sep 17 00:00:00 2001 From: tusharbhardwaj-bk Date: Mon, 31 Aug 2026 19:13:34 +0000 Subject: [PATCH 3/6] feat(mobile): wire the phase sidebar behind a Settings toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the experimental sidebar reachable: Settings → Experiments → Phase-grouped Sidebar. Off by default, so nothing changes for anyone who does not turn it on. The sidebar replaces the whole thread list rather than reshaping it, so the stock list keeps its exact behaviour when the flag is off — and turning the flag off is a clean way to isolate a regression after an upstream merge. Selecting a row records the visit, which is what drives the unread dot. Ownership facets resolve against the environment of the selected thread: mobile has no single primary environment, and this mirrors the rough edge web's identity module already documents. Added to both Settings render sites (phone and tablet); a behaviour reachable one way is usually reachable more than one. Long-press currently selects the row. That is deliberate interim behaviour — the context menu lands next, and a long-press that silently does nothing reads as broken. Verified: 54 tests across the phase sidebar and the stock list's own suite, `vp run typecheck` clean, lint and fmt clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../features/settings/SettingsRouteScreen.tsx | 32 +++++ .../threads/ThreadNavigationSidebar.tsx | 130 +++++++++++++----- .../src/persistence/mobile-preferences.ts | 1 + packages/client-runtime/src/state/session.ts | 5 +- 4 files changed, 132 insertions(+), 36 deletions(-) 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..decd7886ff39 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -32,6 +32,15 @@ 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 { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; +import { PhaseSidebarList } from "../phasesidebar/PhaseSidebarList"; +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 +182,23 @@ function ThreadNavigationSidebarPane( regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); + // T3-CUSTOM(expbkt3): BEGIN — experimental phase sidebar. + const phaseSidebarEnabled = usePhaseSidebarEnabled(); + // 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(); + // T3-CUSTOM(expbkt3): END const preferencesResult = useAtomValue(mobilePreferencesAtom); const autoSettleOnMerge = !AsyncResult.isSuccess(preferencesResult) || @@ -756,6 +782,24 @@ 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 handlePhaseSidebarLongPress = useCallback( + (row: { readonly thread: EnvironmentThreadShell }) => { + // Row actions arrive with the context menu; selecting is the safe + // interim behaviour rather than a long-press that silently does nothing. + props.onSelectThread(row.thread); + }, + [props.onSelectThread], + ); + // T3-CUSTOM(expbkt3): END + const handleSelectThread = useCallback( (thread: EnvironmentThreadShell) => { props.onSelectThread(thread); @@ -1200,41 +1244,57 @@ 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 ? ( + + + + ) : ( + /* 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 b07214bcd803..0b913713329e 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -117,6 +117,7 @@ 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; diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 9210c2c6cb88..0530427f378c 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -1,3 +1,4 @@ +// 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"; @@ -61,8 +62,9 @@ export const fetchEnvironmentSessionState = Effect.fn( ); }); +// T3-CUSTOM(expbkt3): BEGIN /** - * T3-CUSTOM(expbkt3): the operator id an environment session reports, or null. + * 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 @@ -77,6 +79,7 @@ export function operatorUserIdFromSessionState( } return sessionState.userId ?? null; } +// T3-CUSTOM(expbkt3): END export function createEnvironmentSessionAtoms( runtime: Atom.AtomRuntime, From ed3e778c8cc2c92682995c683afdca718ec7888d Mon Sep 17 00:00:00 2001 From: tusharbhardwaj-bk Date: Mon, 31 Aug 2026 19:31:31 +0000 Subject: [PATCH 4/6] feat(mobile): row actions, member tagging and provider quota bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things, all reachable from the phase sidebar. **Row actions.** Long-press a row for People, settle/reopen, snooze/wake, pin/unpin, priority, force stop and archive. The action set is built by a pure function gated on the row model's capability flags, so an older server offers fewer items rather than failing an RPC — and every state that can be entered can be left, per the fork's rule that a one-way door is a bug. Destructive actions sort last. Dispatch reuses the existing `useThreadListActions`, which already handles failure reporting, rather than duplicating its commands. **Member tagging.** `ThreadMembersSheet` adds and removes members and transfers ownership through the `threadCommands` that already existed in client-runtime. Ordering puts the owner first, then existing members, then everyone else alphabetically — scrolling to find who is already tagged is the mistake that ordering avoids. Worth recording a wrong turn: I first wired this to `users.list`, whose `EnvironmentUserId` is a *different id space* from the `UserId` thread membership uses, and the brands caught it. Thread tagging needs the org directory at `/api/orchestration/users`, which had no shared client. Added `fetchOrchestrationUsers` plus a per-environment atom to client-runtime's session module, where the prepared-connection and auth-header plumbing already lives, so web can adopt it later instead of keeping its own HTTP path. **Provider quota bars.** Claude and Codex remaining-percent in the sidebar header, gated on the `providerRateLimits` capability. All the arithmetic was already shared, so this is layout. It deliberately does not duplicate the existing Settings → Usage screen, which already covers both providers with per-day charts — this answers "how much is left" at a glance. Verified: 28 fork-owned tests, `vp run typecheck` clean in mobile and client-runtime, lint and fmt clean. Co-Authored-By: Claude Opus 5 (1M context) --- apps/mobile/src/Stack.tsx | 15 ++ .../features/members/ThreadMembersSheet.tsx | 207 ++++++++++++++++++ .../features/members/threadMembers.test.ts | 114 ++++++++++ .../src/features/members/threadMembers.ts | 74 +++++++ .../phasesidebar/PhaseSidebarList.tsx | 20 +- .../phasesidebar/PhaseSidebarRateLimits.tsx | 108 +++++++++ .../phasesidebar/PhaseSidebarRowView.tsx | 182 +++++++-------- .../usePhaseSidebarRowActions.test.ts | 88 ++++++++ .../phasesidebar/usePhaseSidebarRowActions.ts | 100 +++++++++ .../threads/ThreadNavigationSidebar.tsx | 93 +++++++- packages/client-runtime/src/state/session.ts | 62 ++++++ 11 files changed, 967 insertions(+), 96 deletions(-) create mode 100644 apps/mobile/src/features/members/ThreadMembersSheet.tsx create mode 100644 apps/mobile/src/features/members/threadMembers.test.ts create mode 100644 apps/mobile/src/features/members/threadMembers.ts create mode 100644 apps/mobile/src/features/phasesidebar/PhaseSidebarRateLimits.tsx create mode 100644 apps/mobile/src/features/phasesidebar/usePhaseSidebarRowActions.test.ts create mode 100644 apps/mobile/src/features/phasesidebar/usePhaseSidebarRowActions.ts 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/PhaseSidebarList.tsx b/apps/mobile/src/features/phasesidebar/PhaseSidebarList.tsx index 2322bea81cfc..9f474458d1d5 100644 --- a/apps/mobile/src/features/phasesidebar/PhaseSidebarList.tsx +++ b/apps/mobile/src/features/phasesidebar/PhaseSidebarList.tsx @@ -24,6 +24,7 @@ import { type SidebarThreadSortOrder, type UserId, } from "@t3tools/contracts"; +import type { MenuAction } from "@react-native-menu/menu"; import { useCallback, useMemo, useState } from "react"; import { FlatList, View } from "react-native"; @@ -31,6 +32,7 @@ import { AppText as Text } from "../../components/AppText"; import { cn } from "../../lib/cn"; import { PhaseSidebarRowView } from "./PhaseSidebarRowView"; import { phaseSidebarSectionToneClassName } from "./phaseSidebarRowTone"; +import { buildPhaseSidebarRowActions } from "./usePhaseSidebarRowActions"; /** A flattened list entry: either a lifecycle header or a thread row. */ type PhaseSidebarListItem = @@ -56,7 +58,8 @@ export interface PhaseSidebarListProps { readonly sort?: PhaseSidebarSortPreferences; readonly sortOrder?: SidebarThreadSortOrder; readonly onSelectRow: (row: PhaseSidebarRow) => void; - readonly onLongPressRow: (row: PhaseSidebarRow) => void; + /** Fired with the chosen action id from the row's long-press menu. */ + readonly onRowAction: (row: PhaseSidebarRow, actionId: string) => void; readonly ListHeaderComponent?: React.ComponentProps["ListHeaderComponent"]; } @@ -109,6 +112,18 @@ export function PhaseSidebarList(props: PhaseSidebarListProps) { 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 handleToggleExpanded = useCallback((row: PhaseSidebarRow) => { setCollapsedKeys((current) => { const key = `${row.thread.environmentId}:${row.thread.id}`; @@ -146,8 +161,9 @@ export function PhaseSidebarList(props: PhaseSidebarListProps) { indentDepth={item.node.depth} isActive={props.activeThreadKey === item.node.key} isExpanded={isExpanded(item.node.key)} - onLongPress={props.onLongPressRow} + actions={rowActionsFor(item.node.row)} onPress={props.onSelectRow} + onPressAction={props.onRowAction} onToggleExpanded={handleToggleExpanded} row={item.node.row} subtreeCount={item.node.descendantCount} 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 index 5aedc3578e09..163de36f37a4 100644 --- a/apps/mobile/src/features/phasesidebar/PhaseSidebarRowView.tsx +++ b/apps/mobile/src/features/phasesidebar/PhaseSidebarRowView.tsx @@ -17,10 +17,12 @@ import { } 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 } from "react"; import { Pressable, View } from "react-native"; import { AppText as Text } from "../../components/AppText"; +import { ControlPillMenu } from "../../components/ControlPill"; import { cn } from "../../lib/cn"; import { phaseSidebarCheckoutToneClassName, @@ -37,7 +39,9 @@ export interface PhaseSidebarRowViewProps { readonly subtreeCount: number; readonly isExpanded: boolean; readonly onPress: (row: PhaseSidebarRow) => void; - readonly onLongPress: (row: PhaseSidebarRow) => void; + /** Mutable because MenuView's prop type is not readonly. */ + readonly actions: MenuAction[]; + readonly onPressAction: (row: PhaseSidebarRow, actionId: string) => void; readonly onToggleExpanded: (row: PhaseSidebarRow) => void; } @@ -64,111 +68,115 @@ export const PhaseSidebarRowView = memo(function PhaseSidebarRowView( const priority = thread.priority ?? null; const handlePress = useCallback(() => props.onPress(row), [props, row]); - const handleLongPress = useCallback(() => props.onLongPress(row), [props, row]); + 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} - - - - + + {props.subtreeCount > 0 ? ( + - {thread.title} - - - {compactPhaseSidebarTimeLabel(thread.updatedAt)} - - + + {props.isExpanded ? "⌄" : "›"} + + + {props.subtreeCount} + + + ) : null} - {/* The metadata lane. Order matches web so the two read the same. */} - - - {row.repositoryLabel} - - {worktree.worktreeCodename === null ? null : ( + {row.isUnreadCompletion ? ( + + ) : null} + + + - {worktree.worktreeCodename} - {worktree.worktreeSharedCount > 0 ? ` ×${worktree.worktreeSharedCount}` : ""} + {thread.title} + + + {compactPhaseSidebarTimeLabel(thread.updatedAt)} - )} - {linearIssue === null ? null : ( + + + {/* The metadata lane. Order matches web so the two read the same. */} + - {linearIssue.identifier} - - )} - {mattermost === null ? null : ( - - mm + {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()} + {priority === null || !row.prioritySupported ? null : ( + + {formatThreadPriority(priority)} - - )} - - {providerCode} - + )} + {ownerAvatarUserId === null ? null : ( + + + {ownerAvatarUserId.slice(0, 1).toUpperCase()} + + + )} + + {providerCode} + + - - + + ); }); 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/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index decd7886ff39..94749f2318df 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"; @@ -33,8 +35,12 @@ 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 { PhaseSidebarList } from "../phasesidebar/PhaseSidebarList"; +import { PhaseSidebarRateLimits } from "../phasesidebar/PhaseSidebarRateLimits"; import { usePhaseSidebarEnabled } from "../phasesidebar/phaseSidebarEnabled"; import { usePhaseSidebarRows, @@ -184,6 +190,16 @@ function ThreadNavigationSidebarPane( 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 updatePhaseSidebarPriority = useAtomCommand( + threadEnvironment.updateMetadata, + "phase sidebar set priority", + ); + 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( @@ -790,13 +806,73 @@ function ThreadNavigationSidebarPane( }, [markPhaseSidebarThreadVisited, props.onSelectThread], ); - const handlePhaseSidebarLongPress = useCallback( - (row: { readonly thread: EnvironmentThreadShell }) => { - // Row actions arrive with the context menu; selecting is the safe - // interim behaviour rather than a long-press that silently does nothing. - props.onSelectThread(row.thread); + 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 updatePhaseSidebarPriority({ + 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; + } }, - [props.onSelectThread], + [ + archiveThread, + phaseSidebarNavigation, + pinThread, + settleThread, + snoozeThread, + stopPhaseSidebarExecution, + unpinThread, + unsettleThread, + unsnoozeThread, + updatePhaseSidebarPriority, + ], ); // T3-CUSTOM(expbkt3): END @@ -1250,8 +1326,11 @@ function ThreadNavigationSidebarPane( {phaseSidebarEnabled ? ( + } activeThreadKey={props.selectedThreadKey} - onLongPressRow={handlePhaseSidebarLongPress} + onRowAction={handlePhaseSidebarRowAction} onSelectRow={handlePhaseSidebarSelect} rows={phaseSidebarRows} viewerUserId={phaseSidebarViewerUserId} diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 0530427f378c..04349c56ebac 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -62,6 +62,45 @@ 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. @@ -171,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, @@ -178,5 +238,7 @@ export function createEnvironmentSessionAtoms( preparedConnectionValueAtom, sessionStateAtom, sessionStateValueAtom, + // T3-CUSTOM(expbkt3): org user directory. + orchestrationUsersAtom, }; } From c75cdce428ee4d90e13dd2a064fb95e06f253906 Mon Sep 17 00:00:00 2001 From: tusharbhardwaj-bk Date: Mon, 31 Aug 2026 19:42:24 +0000 Subject: [PATCH 5/6] feat(mobile): drag to reorder pins and re-nest sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-fidelity drag, as asked. Dropping ON a row re-parents it; dropping near a row's top or bottom third reorders a pin. Refused drops tint red with the reason rather than snapping back silently. The rules are a pure module with 23 tests, because a bad drop re-parents real work: it refuses a drop on itself, across environments, onto its own descendant, onto the parent it already has, and anything that would exceed PHASE_SIDEBAR_TREE_MAX_DEPTH — counting the dragged subtree's own depth, not just the row. It also survives a cycle in server data rather than hanging the gesture. **Design decision worth flagging.** I first put the lift on long-press, which was wrong twice over: long-press already opens the row's context menu, and a list-level gesture cannot know which row the finger started on, so `beginDrag` was never called at all. Dragging now starts from an explicit grab handle on the row, which removes both problems — no gesture competes with scroll, and the subject is unambiguous. Re-parenting reuses the same `thread.meta.update` web's setThreadParent calls. Reordering delegates to the existing `movePinnedThread`, which already owns the fractional order-key planning and the `threadPinReorder` capability check. Direction comes from `pinOrderKey` — the sortable key the server assigns — not from `pinnedAt`, which is when the pin happened and says nothing about position. My first attempt compared `pinnedAt` and was simply wrong. **Known limitation:** because `movePinnedThread` moves one position per call, one drag moves a pin one place. Dragging a pin a long way needs repeating. Expressing an arbitrary target would mean duplicating planPinnedMove's fractional-index logic here, which is not worth the divergence. Verified: 51 fork-owned tests across the sidebar and members, `vp run typecheck` clean, lint and fmt clean. No device verification — the gesture feel in particular is unproven. Co-Authored-By: Claude Opus 5 (1M context) --- .../phasesidebar/PhaseSidebarList.tsx | 63 ++++- .../phasesidebar/PhaseSidebarRowView.tsx | 32 ++- .../phasesidebar/phaseSidebarDrag.test.ts | 227 ++++++++++++++++++ .../features/phasesidebar/phaseSidebarDrag.ts | 144 +++++++++++ .../phasesidebar/usePhaseSidebarDrag.ts | 165 +++++++++++++ .../threads/ThreadNavigationSidebar.tsx | 49 +++- 6 files changed, 672 insertions(+), 8 deletions(-) create mode 100644 apps/mobile/src/features/phasesidebar/phaseSidebarDrag.test.ts create mode 100644 apps/mobile/src/features/phasesidebar/phaseSidebarDrag.ts create mode 100644 apps/mobile/src/features/phasesidebar/usePhaseSidebarDrag.ts diff --git a/apps/mobile/src/features/phasesidebar/PhaseSidebarList.tsx b/apps/mobile/src/features/phasesidebar/PhaseSidebarList.tsx index 9f474458d1d5..717d15f2784c 100644 --- a/apps/mobile/src/features/phasesidebar/PhaseSidebarList.tsx +++ b/apps/mobile/src/features/phasesidebar/PhaseSidebarList.tsx @@ -26,6 +26,8 @@ import { } 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"; @@ -33,6 +35,7 @@ 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 = @@ -60,6 +63,10 @@ export interface PhaseSidebarListProps { 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"]; } @@ -124,6 +131,52 @@ export function PhaseSidebarList(props: PhaseSidebarListProps) { [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}`; @@ -134,7 +187,7 @@ export function PhaseSidebarList(props: PhaseSidebarListProps) { }); }, []); - return ( + const list = ( ) : ( ); + + return list; } diff --git a/apps/mobile/src/features/phasesidebar/PhaseSidebarRowView.tsx b/apps/mobile/src/features/phasesidebar/PhaseSidebarRowView.tsx index 163de36f37a4..5c3b119e604d 100644 --- a/apps/mobile/src/features/phasesidebar/PhaseSidebarRowView.tsx +++ b/apps/mobile/src/features/phasesidebar/PhaseSidebarRowView.tsx @@ -18,8 +18,8 @@ import { import { worktreeCodenameToneIndex } from "@t3tools/shared/worktreeCodename"; import type { UserId } from "@t3tools/contracts"; import type { MenuAction, NativeActionEvent } from "@react-native-menu/menu"; -import { memo, useCallback } from "react"; -import { Pressable, View } from "react-native"; +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"; @@ -42,6 +42,17 @@ export interface PhaseSidebarRowViewProps { /** 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; } @@ -68,6 +79,13 @@ export const PhaseSidebarRowView = memo(function PhaseSidebarRowView( 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], @@ -77,7 +95,14 @@ export const PhaseSidebarRowView = memo(function PhaseSidebarRowView( return ( @@ -174,6 +199,7 @@ export const PhaseSidebarRowView = memo(function PhaseSidebarRowView( {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/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/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 94749f2318df..03510c09dadd 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -192,9 +192,9 @@ function ThreadNavigationSidebarPane( const phaseSidebarEnabled = usePhaseSidebarEnabled(); const phaseSidebarNavigation = useNavigation(); // The two row actions useThreadListActions does not cover. - const updatePhaseSidebarPriority = useAtomCommand( + const updatePhaseSidebarThreadMetadata = useAtomCommand( threadEnvironment.updateMetadata, - "phase sidebar set priority", + "phase sidebar update thread metadata", ); const stopPhaseSidebarExecution = useAtomCommand( threadEnvironment.stopExecution, @@ -806,6 +806,45 @@ function ThreadNavigationSidebarPane( }, [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; @@ -816,7 +855,7 @@ function ThreadNavigationSidebarPane( (choice) => choice.value === parsed, )?.value; if (priority === undefined) return; - void updatePhaseSidebarPriority({ + void updatePhaseSidebarThreadMetadata({ environmentId: thread.environmentId, input: { threadId: thread.id, priority }, }); @@ -871,7 +910,7 @@ function ThreadNavigationSidebarPane( unpinThread, unsettleThread, unsnoozeThread, - updatePhaseSidebarPriority, + updatePhaseSidebarThreadMetadata, ], ); // T3-CUSTOM(expbkt3): END @@ -1330,6 +1369,8 @@ function ThreadNavigationSidebarPane( } activeThreadKey={props.selectedThreadKey} + onReorderRow={handlePhaseSidebarReorder} + onReparentRow={handlePhaseSidebarReparent} onRowAction={handlePhaseSidebarRowAction} onSelectRow={handlePhaseSidebarSelect} rows={phaseSidebarRows} From d8ba486c5d41fb4d9b9844722221ea66f69c8d11 Mon Sep 17 00:00:00 2001 From: tusharbhardwaj-bk Date: Mon, 31 Aug 2026 19:46:23 +0000 Subject: [PATCH 6/6] feat(mobile): filter and sort the phase sidebar The Filter pill from the desktop sidebar, with the facets that exist there: lifecycle, repository, provider, "started by me", priority-first and sort direction. Which facets exist, what they match and how they sanitize are all decided in client-runtime, so this is a sheet of toggles over shared logic. Providers list only the drivers actually present in the current rows, so the sheet does not offer Cursor or OpenCode to someone who never runs them. Filters live in the sidebar's own state and are passed to both the sheet and the list, so the two cannot disagree about what is active. The pill tints when any filter is on, and "Clear all" only appears when there is something to clear. Verified: 51 fork-owned tests, `vp run typecheck` clean, lint and fmt clean. Co-Authored-By: Claude Opus 5 (1M context) --- .../phasesidebar/PhaseSidebarFilterSheet.tsx | 172 ++++++++++++++++++ .../threads/ThreadNavigationSidebar.tsx | 57 +++++- 2 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/features/phasesidebar/PhaseSidebarFilterSheet.tsx 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/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 03510c09dadd..81ba1493340b 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -39,6 +39,15 @@ 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"; @@ -214,6 +223,13 @@ function ThreadNavigationSidebarPane( 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 = @@ -1364,9 +1380,48 @@ function ThreadNavigationSidebarPane( keeps its exact behaviour when the flag is off. */} {phaseSidebarEnabled ? ( + {phaseSidebarFilterOpen ? ( + + + + ) : null} + + + + + Lifecycle + + setPhaseSidebarFilterOpen((open) => !open)} + > + + Filter + + + } activeThreadKey={props.selectedThreadKey} onReorderRow={handlePhaseSidebarReorder}