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";...@@ -37,6 +37,7 @@ import IconChevronRight from "@tabler/icons-react-native/IconChevronRight";
37import IconChevronUp from "@tabler/icons-react-native/IconChevronUp";37import IconChevronUp from "@tabler/icons-react-native/IconChevronUp";
38import IconCircle from "@tabler/icons-react-native/IconCircle";38import IconCircle from "@tabler/icons-react-native/IconCircle";
39import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck";39import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck";
40import IconCircleDashed from "@tabler/icons-react-native/IconCircleDashed";
40import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled";41import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled";
41import IconTicket from "@tabler/icons-react-native/IconTicket";42import IconTicket from "@tabler/icons-react-native/IconTicket";
42import IconClock from "@tabler/icons-react-native/IconClock";43import IconClock from "@tabler/icons-react-native/IconClock";
...@@ -75,6 +76,7 @@ import IconMicrophone from "@tabler/icons-react-native/IconMicrophone";...@@ -75,6 +76,7 @@ import IconMicrophone from "@tabler/icons-react-native/IconMicrophone";
75import IconLink from "@tabler/icons-react-native/IconLink";76import IconLink from "@tabler/icons-react-native/IconLink";
76import IconListNumbers from "@tabler/icons-react-native/IconListNumbers";77import IconListNumbers from "@tabler/icons-react-native/IconListNumbers";
77import IconMenu2 from "@tabler/icons-react-native/IconMenu2";78import IconMenu2 from "@tabler/icons-react-native/IconMenu2";
79import IconMessageCircleQuestion from "@tabler/icons-react-native/IconMessageCircleQuestion";
78import IconMessage from "@tabler/icons-react-native/IconMessage";80import IconMessage from "@tabler/icons-react-native/IconMessage";
79import IconMinus from "@tabler/icons-react-native/IconMinus";81import IconMinus from "@tabler/icons-react-native/IconMinus";
80import IconMoon from "@tabler/icons-react-native/IconMoon";82import IconMoon from "@tabler/icons-react-native/IconMoon";
...@@ -98,6 +100,7 @@ import IconStar from "@tabler/icons-react-native/IconStar";...@@ -98,6 +100,7 @@ import IconStar from "@tabler/icons-react-native/IconStar";
98import IconStarFilled from "@tabler/icons-react-native/IconStarFilled";100import IconStarFilled from "@tabler/icons-react-native/IconStarFilled";
99import IconStethoscope from "@tabler/icons-react-native/IconStethoscope";101import IconStethoscope from "@tabler/icons-react-native/IconStethoscope";
100import IconSun from "@tabler/icons-react-native/IconSun";102import IconSun from "@tabler/icons-react-native/IconSun";
103import IconShieldQuestion from "@tabler/icons-react-native/IconShieldQuestion";
101import IconTarget from "@tabler/icons-react-native/IconTarget";104import IconTarget from "@tabler/icons-react-native/IconTarget";
102import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";105import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";
103import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease";106import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease";
...@@ -148,6 +151,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {...@@ -148,6 +151,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
148 checkmark: IconCheck,151 checkmark: IconCheck,
149 "checkmark.circle": IconCircleCheck,152 "checkmark.circle": IconCircleCheck,
150 circle: IconCircle,153 circle: IconCircle,
154 "circle.dashed": IconCircleDashed,
151 clock: IconClock,155 clock: IconClock,
152 timer: IconClock,156 timer: IconClock,
153 ticket: IconTicket,157 ticket: IconTicket,
...@@ -221,6 +225,8 @@ const ANDROID_ICON_BY_SF_SYMBOL = {...@@ -221,6 +225,8 @@ const ANDROID_ICON_BY_SF_SYMBOL = {
221 terminal: IconTerminal2,225 terminal: IconTerminal2,
222 "text.alignleft": IconAlignLeft,226 "text.alignleft": IconAlignLeft,
223 "text.bubble": IconMessage,227 "text.bubble": IconMessage,
228 "questionmark.bubble": IconMessageCircleQuestion,
229 "exclamationmark.shield": IconShieldQuestion,
224 "text.word.spacing": IconLetterSpacing,230 "text.word.spacing": IconLetterSpacing,
225 "textformat.size": IconTypography,231 "textformat.size": IconTypography,
226 "textformat.size.larger": IconTextIncrease,232 "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";...@@ -25,7 +25,7 @@ import { Alert, Pressable, useWindowDimensions, View } from "react-native";
25import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";25import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
2626
27import type { ThreadListProvider } from "../../state/thread-list-environments";27import type { ThreadListProvider } from "../../state/thread-list-environments";
28import { SymbolView } from "../../components/AppSymbol";28import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
29import { AppText as Text } from "../../components/AppText";29import { AppText as Text } from "../../components/AppText";
30import { ControlPillMenu } from "../../components/ControlPill";30import { ControlPillMenu } from "../../components/ControlPill";
31import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";31import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol";
...@@ -51,6 +51,7 @@ import {...@@ -51,6 +51,7 @@ import {
51 type ThreadListV2Status,51 type ThreadListV2Status,
52} from "./threadListV2";52} from "./threadListV2";
53import { QueuedMessageIcon } from "./queued-message-icon";53import { QueuedMessageIcon } from "./queued-message-icon";
54import { shouldRecedeThreadRow } from "./thread-row-emphasis";
54import { ThreadSearchMatchExcerpt } from "./thread-search-match";55import { ThreadSearchMatchExcerpt } from "./thread-search-match";
5556
56/**57/**
...@@ -63,14 +64,50 @@ import { ThreadSearchMatchExcerpt } from "./thread-search-match";...@@ -63,14 +64,50 @@ import { ThreadSearchMatchExcerpt } from "./thread-search-match";
63// Status hues follow the system-wide convention set by sidebar v1 and the64// Status hues follow the system-wide convention set by sidebar v1 and the
64// Live Activity/widgets (amber approval, indigo input, sky working) so a65// Live Activity/widgets (amber approval, indigo input, sky working) so a
65// thread reads the same color everywhere it surfaces.66// thread reads the same color everywhere it surfaces.
66const STATUS_LABEL_BY_STATUS: Partial<67// Icons match the web sidebar's status glyphs.
67 Record<ThreadListV2Status, { label: string; className: string }>68interface StatusLabel {
68> = {69 readonly label: string;
69 approval: { label: "Approval", className: "text-warning-foreground" },70 readonly icon: AppSymbolName;
70 input: { label: "Input", className: "text-adaptive-indigo-600-300" },71 readonly className: string;
71 working: { label: "Working", className: "text-adaptive-sky-600-400" },72 readonly iconTintClassName: string;
72 failed: { label: "Failed", className: "text-danger-foreground" },73}
73 limited: { label: "Limited", className: "text-warning-foreground" },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",
74};111};
75112
76// Menus keep lifecycle and title regeneration together. Archive keeps its113// Menus keep lifecycle and title regeneration together. Archive keeps its
...@@ -593,8 +630,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -593,8 +630,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
593 // A native /goal keeps the agent going across turns until it is met.630 // A native /goal keeps the agent going across turns until it is met.
594 (status === "working" && workingLabel !== undefined && thread.goal?.status === "active"631 (status === "working" && workingLabel !== undefined && thread.goal?.status === "active"
595 ? { ...workingLabel, label: "Goal" }632 ? { ...workingLabel, label: "Goal" }
596 : workingLabel) ??633 : workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined);
597 (isUnread ? { label: "Done", className: "text-adaptive-emerald-700-300" } : undefined);634 const recede = shouldRecedeThreadRow({ status, selected });
598 // The timestamp is precomputed on the list item (same stamps the settled635 // The timestamp is precomputed on the list item (same stamps the settled
599 // tail sorts by) so a minute tick only re-renders rows that draw it.636 // tail sorts by) so a minute tick only re-renders rows that draw it.
600 const timeLabel = props.timeLabel;637 const timeLabel = props.timeLabel;
...@@ -959,21 +996,44 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -959,21 +996,44 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
959 type="monochrome"996 type="monochrome"
960 />997 />
961 ) : null}998 ) : null}
962 <Text999 {statusLabel ? (
963 className={cn(1000 <View className="flex-row items-center gap-1">
964 "text-xs tabular-nums",1001 <SymbolView
965 statusLabel?.className ??1002 name={statusLabel.icon}
966 (selected1003 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
967 ? selectedThreadRowColors.foregroundClassName1024 ? selectedThreadRowColors.foregroundClassName
968 : rowAppearance.tertiaryForegroundClassName),1025 : rowAppearance.tertiaryForegroundClassName,
969 )}1026 )}
970 >1027 >
971 {statusLabel?.label ?? timeLabel}1028 {timeLabel}
972 </Text>1029 </Text>
1030 )}
973 </View>1031 </View>
974 <Text1032 <Text
975 className={cn(1033 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",
977 selected1037 selected
978 ? selectedThreadRowColors.foregroundClassName1038 ? selectedThreadRowColors.foregroundClassName
979 : rowAppearance.foregroundClassName,1039 : rowAppearance.foregroundClassName,
...@@ -1115,6 +1175,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -1115,6 +1175,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
1115 </>1175 </>
1116 );1176 );
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
1118 const rowContent = (close: () => void) =>1186 const rowContent = (close: () => void) =>
1119 variant === "card" ? (1187 variant === "card" ? (
1120 <RowPressable1188 <RowPressable
...@@ -1135,14 +1203,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {...@@ -1135,14 +1203,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
1135 style={rowAppearance.cardStyle}1203 style={rowAppearance.cardStyle}
1136 >1204 >
1137 {sidebarPane ? (1205 {sidebarPane ? (
1138 cardContent1206 fadedCardContent
1139 ) : (1207 ) : (
1140 /* Flat native list rows: no tonal containers — colored status1208 /* Flat native list rows: no tonal containers — colored status
1141 labels and text hierarchy carry state, an inset hairline1209 labels and text hierarchy carry state, an inset hairline
1142 separates rows. The opaque screen background stays so swipe1210 separates rows. The opaque screen background stays so swipe
1143 actions reveal behind the row. */1211 actions reveal behind the row. */
1144 <View>1212 <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>
1146 {THREAD_LIST_V2_ROW_DIVIDERS && props.showTrailingDivider !== false ? (1214 {THREAD_LIST_V2_ROW_DIVIDERS && props.showTrailingDivider !== false ? (
1147 <View className="ml-5 h-px bg-border-subtle" />1215 <View className="ml-5 h-px bg-border-subtle" />
1148 ) : null}1216 ) : 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}