authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-08 17:07:44-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 00:07:44+00:00
logc724c7cb02aaeb0a23ff6e5e224d17c0fdb9a36b
tree5080b234f5a922146e14c1afb2ef7d7e7dc4f0ae
parent393ff4c968073d8419e5bade06000998602af6ad
signature Signed by PGP key B5690EEEBB952194

feat(mobile): fade working threads and match web's status labels (#17368)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

4 files changed, 131 insertions(+), 24 deletions(-)

apps/mobile/src/components/AppSymbol.tsx+6
......@@ -37,6 +37,7 @@ import IconChevronRight from "@tabler/icons-react-native/IconChevronRight";
3737import IconChevronUp from "@tabler/icons-react-native/IconChevronUp";
3838import IconCircle from "@tabler/icons-react-native/IconCircle";
3939import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck";
40import IconCircleDashed from "@tabler/icons-react-native/IconCircleDashed";
4041import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled";
4142import IconTicket from "@tabler/icons-react-native/IconTicket";
4243import IconClock from "@tabler/icons-react-native/IconClock";
......@@ -75,6 +76,7 @@ import IconMicrophone from "@tabler/icons-react-native/IconMicrophone";
7576import IconLink from "@tabler/icons-react-native/IconLink";
7677import IconListNumbers from "@tabler/icons-react-native/IconListNumbers";
7778import IconMenu2 from "@tabler/icons-react-native/IconMenu2";
79import IconMessageCircleQuestion from "@tabler/icons-react-native/IconMessageCircleQuestion";
7880import IconMessage from "@tabler/icons-react-native/IconMessage";
7981import IconMinus from "@tabler/icons-react-native/IconMinus";
8082import IconMoon from "@tabler/icons-react-native/IconMoon";
......@@ -98,6 +100,7 @@ import IconStar from "@tabler/icons-react-native/IconStar";
98100import IconStarFilled from "@tabler/icons-react-native/IconStarFilled";
99101import IconStethoscope from "@tabler/icons-react-native/IconStethoscope";
100102import IconSun from "@tabler/icons-react-native/IconSun";
103import IconShieldQuestion from "@tabler/icons-react-native/IconShieldQuestion";
101104import IconTarget from "@tabler/icons-react-native/IconTarget";
102105import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";
103106import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease";
......@@ -148,6 +151,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
148151 checkmark: IconCheck,
149152 "checkmark.circle": IconCircleCheck,
150153 circle: IconCircle,
154 "circle.dashed": IconCircleDashed,
151155 clock: IconClock,
152156 timer: IconClock,
153157 ticket: IconTicket,
......@@ -221,6 +225,8 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
221225 terminal: IconTerminal2,
222226 "text.alignleft": IconAlignLeft,
223227 "text.bubble": IconMessage,
228 "questionmark.bubble": IconMessageCircleQuestion,
229 "exclamationmark.shield": IconShieldQuestion,
224230 "text.word.spacing": IconLetterSpacing,
225231 "textformat.size": IconTypography,
226232 "textformat.size.larger": IconTextIncrease,
apps/mobile/src/features/threads/thread-list-v2-items.tsx+92-24
......@@ -25,7 +25,7 @@ import { Alert, Pressable, useWindowDimensions, View } from "react-native";
2525import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
2626
2727import type { ThreadListProvider } from "../../state/thread-list-environments";
28import { SymbolView } from "../../components/AppSymbol";
28import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
2929import { AppText as Text } from "../../components/AppText";
3030import { ControlPillMenu } from "../../components/ControlPill";
3131import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
......@@ -51,6 +51,7 @@ import {
5151 type ThreadListV2Status,
5252} from "./threadListV2";
5353import { QueuedMessageIcon } from "./queued-message-icon";
54import { shouldRecedeThreadRow } from "./thread-row-emphasis";
5455import { ThreadSearchMatchExcerpt } from "./thread-search-match";
5556
5657/**
......@@ -63,14 +64,50 @@ import { ThreadSearchMatchExcerpt } from "./thread-search-match";
6364// Status hues follow the system-wide convention set by sidebar v1 and the
6465// Live Activity/widgets (amber approval, indigo input, sky working) so a
6566// thread reads the same color everywhere it surfaces.
66const STATUS_LABEL_BY_STATUS: Partial<
67 Record<ThreadListV2Status, { label: string; className: string }>
68> = {
69 approval: { label: "Approval", className: "text-warning-foreground" },
70 input: { label: "Input", className: "text-adaptive-indigo-600-300" },
71 working: { label: "Working", className: "text-adaptive-sky-600-400" },
72 failed: { label: "Failed", className: "text-danger-foreground" },
73 limited: { label: "Limited", className: "text-warning-foreground" },
67// Icons match the web sidebar's status glyphs.
68interface StatusLabel {
69 readonly label: string;
70 readonly icon: AppSymbolName;
71 readonly className: string;
72 readonly iconTintClassName: string;
73}
74const STATUS_LABEL_BY_STATUS: Partial<Record<ThreadListV2Status, StatusLabel>> = {
75 approval: {
76 label: "Approval",
77 icon: "exclamationmark.shield",
78 className: "text-warning-foreground",
79 iconTintClassName: "accent-warning-foreground",
80 },
81 input: {
82 label: "Input",
83 icon: "questionmark.bubble",
84 className: "text-adaptive-indigo-600-300",
85 iconTintClassName: "accent-adaptive-indigo-600-300",
86 },
87 working: {
88 label: "Working",
89 icon: "circle.dashed",
90 className: "text-adaptive-sky-600-400",
91 iconTintClassName: "accent-adaptive-sky-600-400",
92 },
93 failed: {
94 label: "Failed",
95 icon: "exclamationmark.circle",
96 className: "text-danger-foreground",
97 iconTintClassName: "accent-danger-foreground",
98 },
99 limited: {
100 label: "Limited",
101 icon: "exclamationmark.circle",
102 className: "text-warning-foreground",
103 iconTintClassName: "accent-warning-foreground",
104 },
105};
106const DONE_STATUS_LABEL: StatusLabel = {
107 label: "Done",
108 icon: "checkmark.circle",
109 className: "text-adaptive-emerald-700-300",
110 iconTintClassName: "accent-adaptive-emerald-700-300",
74111};
75112
76113// Menus keep lifecycle and title regeneration together. Archive keeps its
......@@ -593,8 +630,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
593630 // A native /goal keeps the agent going across turns until it is met.
594631 (status === "working" && workingLabel !== undefined && thread.goal?.status === "active"
595632 ? { ...workingLabel, label: "Goal" }
596 : workingLabel) ??
597 (isUnread ? { label: "Done", className: "text-adaptive-emerald-700-300" } : undefined);
633 : workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined);
634 const recede = shouldRecedeThreadRow({ status, selected });
598635 // The timestamp is precomputed on the list item (same stamps the settled
599636 // tail sorts by) so a minute tick only re-renders rows that draw it.
600637 const timeLabel = props.timeLabel;
......@@ -959,21 +996,44 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
959996 type="monochrome"
960997 />
961998 ) : null}
962 <Text
963 className={cn(
964 "text-xs tabular-nums",
965 statusLabel?.className ??
966 (selected
999 {statusLabel ? (
1000 <View className="flex-row items-center gap-1">
1001 <SymbolView
1002 name={statusLabel.icon}
1003 size={13}
1004 tintColorClassName={
1005 selected ? selectedThreadRowColors.iconTintClassName : statusLabel.iconTintClassName
1006 }
1007 type="monochrome"
1008 weight="semibold"
1009 />
1010 <Text
1011 className={cn(
1012 "text-xs font-t3-bold",
1013 selected ? selectedThreadRowColors.foregroundClassName : statusLabel.className,
1014 )}
1015 >
1016 {statusLabel.label}
1017 </Text>
1018 </View>
1019 ) : (
1020 <Text
1021 className={cn(
1022 "text-xs tabular-nums",
1023 selected
9671024 ? selectedThreadRowColors.foregroundClassName
968 : rowAppearance.tertiaryForegroundClassName),
969 )}
970 >
971 {statusLabel?.label ?? timeLabel}
972 </Text>
1025 : rowAppearance.tertiaryForegroundClassName,
1026 )}
1027 >
1028 {timeLabel}
1029 </Text>
1030 )}
9731031 </View>
9741032 <Text
9751033 className={cn(
976 "mt-1 text-base font-t3-medium",
1034 "mt-1 text-base",
1035 // Background work recedes to regular weight, matching web.
1036 !recede && "font-t3-medium",
9771037 selected
9781038 ? selectedThreadRowColors.foregroundClassName
9791039 : rowAppearance.foregroundClassName,
......@@ -1115,6 +1175,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
11151175 </>
11161176 );
11171177
1178 // Background work fades as a whole, status label included, so it takes
1179 // less attention than rows that need a human (input, approval, done).
1180 const fadedCardContent = recede ? (
1181 <View style={{ opacity: 0.55 }}>{cardContent}</View>
1182 ) : (
1183 cardContent
1184 );
1185
11181186 const rowContent = (close: () => void) =>
11191187 variant === "card" ? (
11201188 <RowPressable
......@@ -1135,14 +1203,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
11351203 style={rowAppearance.cardStyle}
11361204 >
11371205 {sidebarPane ? (
1138 cardContent
1206 fadedCardContent
11391207 ) : (
11401208 /* Flat native list rows: no tonal containers — colored status
11411209 labels and text hierarchy carry state, an inset hairline
11421210 separates rows. The opaque screen background stays so swipe
11431211 actions reveal behind the row. */
11441212 <View>
1145 <View className={THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME}>{cardContent}</View>
1213 <View className={THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME}>{fadedCardContent}</View>
11461214 {THREAD_LIST_V2_ROW_DIVIDERS && props.showTrailingDivider !== false ? (
11471215 <View className="ml-5 h-px bg-border-subtle" />
11481216 ) : null}
apps/mobile/src/features/threads/thread-row-emphasis.test.ts created+20
......@@ -0,0 +1,20 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { shouldRecedeThreadRow } from "./thread-row-emphasis";
4
5describe("shouldRecedeThreadRow", () => {
6 it("recedes background work", () => {
7 expect(shouldRecedeThreadRow({ status: "working", selected: false })).toBe(true);
8 expect(shouldRecedeThreadRow({ status: "waiting", selected: false })).toBe(true);
9 });
10
11 it("keeps rows that need a human at full strength", () => {
12 for (const status of ["approval", "input", "failed", "limited", "ready"] as const) {
13 expect(shouldRecedeThreadRow({ status, selected: false })).toBe(false);
14 }
15 });
16
17 it("never fades the selected row", () => {
18 expect(shouldRecedeThreadRow({ status: "working", selected: true })).toBe(false);
19 });
20});
apps/mobile/src/features/threads/thread-row-emphasis.ts created+13
......@@ -0,0 +1,13 @@
1import type { ThreadListV2Status } from "./threadListV2";
2
3/**
4 * Mirrors web's recede rule: background work fades so rows that need a
5 * human (approval, input, failure, unseen completion) stand out. An unseen
6 * completion cannot pull a still-working thread forward.
7 */
8export function shouldRecedeThreadRow(input: {
9 readonly status: ThreadListV2Status;
10 readonly selected: boolean;
11}): boolean {
12 return !input.selected && (input.status === "working" || input.status === "waiting");
13}