| author | |
| committer | |
| log | 8619ef22e9a173678b1ed2d00eae99b329a7c6b6 |
| tree | 37c1079f72e3897cff9f621ceb21a1b7c4135c41 |
| parent | 843cf176eae729838b6a2cc8c68cbdadda667d95 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>4 files changed, 132 insertions(+), 40 deletions(-)
apps/mobile/src/features/threads/thread-list-items.tsx+54-8| ... | ... | @@ -6,8 +6,9 @@ import type { |
| 6 | 6 | import type { MenuAction } from "@react-native-menu/menu"; |
| 7 | 7 | import { SymbolView } from "expo-symbols"; |
| 8 | 8 | import { memo, useCallback, useMemo, type ComponentProps } from "react"; |
| 9 | import { Pressable, useWindowDimensions, View } from "react-native"; | |
| 9 | import { Pressable, useColorScheme, useWindowDimensions, View } from "react-native"; | |
| 10 | 10 | import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; |
| 11 | import Svg, { Circle, Path } from "react-native-svg"; | |
| 11 | 12 | |
| 12 | 13 | import { AppText as Text } from "../../components/AppText"; |
| 13 | 14 | import { ControlPillMenu } from "../../components/ControlPill"; |
| ... | ... | @@ -16,7 +17,7 @@ import { cn } from "../../lib/cn"; |
| 16 | 17 | import { relativeTime } from "../../lib/time"; |
| 17 | 18 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 18 | 19 | import type { PendingNewTask } from "../../state/use-pending-new-tasks"; |
| 19 | import { useThreadPr } from "../../state/use-thread-pr"; | |
| 20 | import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; | |
| 20 | 21 | import type { HomeGroupDisplayAction } from "../home/homeListItems"; |
| 21 | 22 | import { ThreadSwipeable } from "../home/thread-swipe-actions"; |
| 22 | 23 | import { resolveThreadStatus } from "./threadPresentation"; |
| ... | ... | @@ -33,6 +34,41 @@ export type ThreadListVariant = "compact" | "sidebar"; |
| 33 | 34 | export const THREAD_LIST_COMPACT_INSET = 20; |
| 34 | 35 | const SIDEBAR_ROW_RADIUS = 12; |
| 35 | 36 | |
| 37 | function pullRequestTintColor( | |
| 38 | state: ThreadPr["state"], | |
| 39 | colorScheme: ReturnType<typeof useColorScheme>, | |
| 40 | ) { | |
| 41 | const dark = colorScheme === "dark"; | |
| 42 | switch (state) { | |
| 43 | case "open": | |
| 44 | return dark ? "#34d399" : "#059669"; | |
| 45 | case "merged": | |
| 46 | return dark ? "#a78bfa" : "#7c3aed"; | |
| 47 | case "closed": | |
| 48 | return dark ? "#a1a1aa" : "#71717a"; | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | function PullRequestIcon(props: { readonly size: number; readonly color: string }) { | |
| 53 | return ( | |
| 54 | <Svg | |
| 55 | width={props.size} | |
| 56 | height={props.size} | |
| 57 | viewBox="0 0 24 24" | |
| 58 | fill="none" | |
| 59 | stroke={props.color} | |
| 60 | strokeWidth={2} | |
| 61 | strokeLinecap="round" | |
| 62 | strokeLinejoin="round" | |
| 63 | > | |
| 64 | <Circle cx={18} cy={18} r={3} /> | |
| 65 | <Circle cx={6} cy={6} r={3} /> | |
| 66 | <Path d="M13 6h3a2 2 0 0 1 2 2v7" /> | |
| 67 | <Path d="M6 9v12" /> | |
| 68 | </Svg> | |
| 69 | ); | |
| 70 | } | |
| 71 | ||
| 36 | 72 | /* ─── Project group header ───────────────────────────────────────────── */ |
| 37 | 73 | |
| 38 | 74 | export const ThreadListGroupHeader = memo(function ThreadListGroupHeader(props: { |
| ... | ... | @@ -394,6 +430,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 394 | 430 | >["simultaneousWithExternalGesture"]; |
| 395 | 431 | }) { |
| 396 | 432 | const { width: windowWidth } = useWindowDimensions(); |
| 433 | const colorScheme = useColorScheme(); | |
| 397 | 434 | const compact = props.variant === "compact"; |
| 398 | 435 | const selected = props.selected === true; |
| 399 | 436 | // Recycling-safe: resets when the list container is reused for another |
| ... | ... | @@ -479,13 +516,22 @@ export const ThreadListRow = memo(function ThreadListRow(props: { |
| 479 | 516 | </> |
| 480 | 517 | ) : null} |
| 481 | 518 | {pr !== null ? ( |
| 482 | <Text | |
| 483 | className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${ | |
| 484 | selected ? "text-white" : pr.textClassName | |
| 485 | }`} | |
| 519 | <View | |
| 520 | accessibilityLabel={pr.accessibilityLabel} | |
| 521 | className="flex-row items-center gap-0.5" | |
| 486 | 522 | > |
| 487 | {pr.label} | |
| 488 | </Text> | |
| 523 | <PullRequestIcon | |
| 524 | size={compact ? 13 : 11} | |
| 525 | color={selected ? "#ffffff" : pullRequestTintColor(pr.state, colorScheme)} | |
| 526 | /> | |
| 527 | <Text | |
| 528 | className={`${compact ? "text-sm" : "text-xs"} font-t3-medium ${ | |
| 529 | selected ? "text-white" : pr.textClassName | |
| 530 | }`} | |
| 531 | > | |
| 532 | {pr.label} | |
| 533 | </Text> | |
| 534 | </View> | |
| 489 | 535 | ) : null} |
| 490 | 536 | </View> |
| 491 | 537 | ) : null; |
apps/mobile/src/state/thread-pr-presentation.ts created+36| ... | ... | @@ -0,0 +1,36 @@ |
| 1 | import type { VcsStatusResult } from "@t3tools/contracts"; | |
| 2 | import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl"; | |
| 3 | ||
| 4 | export type ThreadPr = NonNullable<VcsStatusResult["pr"]>; | |
| 5 | ||
| 6 | export interface ThreadPrPresentation { | |
| 7 | readonly number: number; | |
| 8 | readonly state: ThreadPr["state"]; | |
| 9 | readonly url: string; | |
| 10 | /** Compact desktop-style label, e.g. "#3774". */ | |
| 11 | readonly label: string; | |
| 12 | /** Full, provider-aware label for assistive technologies. */ | |
| 13 | readonly accessibilityLabel: string; | |
| 14 | readonly textClassName: string; | |
| 15 | } | |
| 16 | ||
| 17 | const PR_STATE_TEXT_CLASS: Record<ThreadPr["state"], string> = { | |
| 18 | open: "text-emerald-600 dark:text-emerald-400", | |
| 19 | merged: "text-violet-600 dark:text-violet-400", | |
| 20 | closed: "text-zinc-500 dark:text-zinc-400", | |
| 21 | }; | |
| 22 | ||
| 23 | export function presentThreadPr( | |
| 24 | pr: ThreadPr, | |
| 25 | provider: VcsStatusResult["sourceControlProvider"] | null | undefined, | |
| 26 | ): ThreadPrPresentation { | |
| 27 | const presentation = resolveChangeRequestPresentation(provider); | |
| 28 | return { | |
| 29 | number: pr.number, | |
| 30 | state: pr.state, | |
| 31 | url: pr.url, | |
| 32 | label: `#${pr.number}`, | |
| 33 | accessibilityLabel: `#${pr.number} ${presentation.longName} ${pr.state}`, | |
| 34 | textClassName: PR_STATE_TEXT_CLASS[pr.state], | |
| 35 | }; | |
| 36 | } |
apps/mobile/src/state/use-thread-pr.test.ts created+36| ... | ... | @@ -0,0 +1,36 @@ |
| 1 | import type { VcsStatusResult } from "@t3tools/contracts"; | |
| 2 | import { describe, expect, it } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { presentThreadPr } from "./thread-pr-presentation"; | |
| 5 | ||
| 6 | const pullRequest: NonNullable<VcsStatusResult["pr"]> = { | |
| 7 | number: 3774, | |
| 8 | title: "Desktop-style pull request indicator", | |
| 9 | url: "https://github.com/t3tools/t3code/pull/3774", | |
| 10 | baseRef: "main", | |
| 11 | headRef: "codex/desktop-style-pr-indicator", | |
| 12 | state: "merged", | |
| 13 | }; | |
| 14 | ||
| 15 | describe("presentThreadPr", () => { | |
| 16 | it("uses the compact desktop-style pull request number label", () => { | |
| 17 | expect(presentThreadPr(pullRequest, undefined)).toMatchObject({ | |
| 18 | label: "#3774", | |
| 19 | accessibilityLabel: "#3774 pull request merged", | |
| 20 | textClassName: "text-violet-600 dark:text-violet-400", | |
| 21 | }); | |
| 22 | }); | |
| 23 | ||
| 24 | it("uses merge-request terminology for GitLab", () => { | |
| 25 | expect( | |
| 26 | presentThreadPr(pullRequest, { | |
| 27 | kind: "gitlab", | |
| 28 | name: "GitLab", | |
| 29 | baseUrl: "https://gitlab.com", | |
| 30 | }), | |
| 31 | ).toMatchObject({ | |
| 32 | label: "#3774", | |
| 33 | accessibilityLabel: "#3774 merge request merged", | |
| 34 | }); | |
| 35 | }); | |
| 36 | }); |
apps/mobile/src/state/use-thread-pr.ts+6-32| ... | ... | @@ -1,40 +1,14 @@ |
| 1 | 1 | import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; |
| 2 | import type { VcsStatusResult } from "@t3tools/contracts"; | |
| 3 | import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl"; | |
| 4 | 2 | |
| 5 | 3 | import { useEnvironmentQuery } from "./query"; |
| 4 | import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; | |
| 6 | 5 | import { vcsEnvironment } from "./vcs"; |
| 7 | 6 | |
| 8 | export type ThreadPr = NonNullable<VcsStatusResult["pr"]>; | |
| 9 | ||
| 10 | export interface ThreadPrPresentation { | |
| 11 | readonly number: number; | |
| 12 | readonly state: ThreadPr["state"]; | |
| 13 | readonly url: string; | |
| 14 | /** Compact chip label, e.g. "PR open" / "MR merged". */ | |
| 15 | readonly label: string; | |
| 16 | readonly textClassName: string; | |
| 17 | } | |
| 18 | ||
| 19 | const PR_STATE_TEXT_CLASS: Record<ThreadPr["state"], string> = { | |
| 20 | open: "text-emerald-600 dark:text-emerald-400", | |
| 21 | merged: "text-violet-600 dark:text-violet-400", | |
| 22 | closed: "text-zinc-500 dark:text-zinc-400", | |
| 23 | }; | |
| 24 | ||
| 25 | export function presentThreadPr( | |
| 26 | pr: ThreadPr, | |
| 27 | provider: VcsStatusResult["sourceControlProvider"] | null | undefined, | |
| 28 | ): ThreadPrPresentation { | |
| 29 | const shortName = resolveChangeRequestPresentation(provider).shortName; | |
| 30 | return { | |
| 31 | number: pr.number, | |
| 32 | state: pr.state, | |
| 33 | url: pr.url, | |
| 34 | label: `${shortName} ${pr.state}`, | |
| 35 | textClassName: PR_STATE_TEXT_CLASS[pr.state], | |
| 36 | }; | |
| 37 | } | |
| 7 | export { | |
| 8 | presentThreadPr, | |
| 9 | type ThreadPr, | |
| 10 | type ThreadPrPresentation, | |
| 11 | } from "./thread-pr-presentation"; | |
| 38 | 12 | |
| 39 | 13 | /** |
| 40 | 14 | * Live PR status for a thread's branch. Subscriptions are deduplicated per |