From c724c7cb02aaeb0a23ff6e5e224d17c0fdb9a36b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 8 Oct 2026 17:07:44 -0700 Subject: [PATCH] feat(mobile): fade working threads and match web's status labels (#17368) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/components/AppSymbol.tsx | 6 + .../features/threads/thread-list-v2-items.tsx | 116 ++++++++++++++---- .../threads/thread-row-emphasis.test.ts | 20 +++ .../features/threads/thread-row-emphasis.ts | 13 ++ 4 files changed, 131 insertions(+), 24 deletions(-) create mode 100644 apps/mobile/src/features/threads/thread-row-emphasis.test.ts create mode 100644 apps/mobile/src/features/threads/thread-row-emphasis.ts diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 9e9a6e1173c7038fc50a650926eef5ea9112fb80..122ad1658e2769941cd6f370e4ee055da3b14d77 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -37,6 +37,7 @@ import IconChevronRight from "@tabler/icons-react-native/IconChevronRight"; import IconChevronUp from "@tabler/icons-react-native/IconChevronUp"; import IconCircle from "@tabler/icons-react-native/IconCircle"; import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck"; +import IconCircleDashed from "@tabler/icons-react-native/IconCircleDashed"; import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled"; import IconTicket from "@tabler/icons-react-native/IconTicket"; import IconClock from "@tabler/icons-react-native/IconClock"; @@ -75,6 +76,7 @@ import IconMicrophone from "@tabler/icons-react-native/IconMicrophone"; import IconLink from "@tabler/icons-react-native/IconLink"; import IconListNumbers from "@tabler/icons-react-native/IconListNumbers"; import IconMenu2 from "@tabler/icons-react-native/IconMenu2"; +import IconMessageCircleQuestion from "@tabler/icons-react-native/IconMessageCircleQuestion"; import IconMessage from "@tabler/icons-react-native/IconMessage"; import IconMinus from "@tabler/icons-react-native/IconMinus"; import IconMoon from "@tabler/icons-react-native/IconMoon"; @@ -98,6 +100,7 @@ import IconStar from "@tabler/icons-react-native/IconStar"; import IconStarFilled from "@tabler/icons-react-native/IconStarFilled"; import IconStethoscope from "@tabler/icons-react-native/IconStethoscope"; import IconSun from "@tabler/icons-react-native/IconSun"; +import IconShieldQuestion from "@tabler/icons-react-native/IconShieldQuestion"; import IconTarget from "@tabler/icons-react-native/IconTarget"; import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2"; import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease"; @@ -148,6 +151,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { checkmark: IconCheck, "checkmark.circle": IconCircleCheck, circle: IconCircle, + "circle.dashed": IconCircleDashed, clock: IconClock, timer: IconClock, ticket: IconTicket, @@ -221,6 +225,8 @@ const ANDROID_ICON_BY_SF_SYMBOL = { terminal: IconTerminal2, "text.alignleft": IconAlignLeft, "text.bubble": IconMessage, + "questionmark.bubble": IconMessageCircleQuestion, + "exclamationmark.shield": IconShieldQuestion, "text.word.spacing": IconLetterSpacing, "textformat.size": IconTypography, "textformat.size.larger": IconTextIncrease, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 4bc4328a578428f1e3c1d50c1db856e07be478a5..88ad0d43ea65d42d721ef16c9e3dec1c17c21410 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -25,7 +25,7 @@ import { Alert, Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import type { ThreadListProvider } from "../../state/thread-list-environments"; -import { SymbolView } from "../../components/AppSymbol"; +import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; @@ -51,6 +51,7 @@ import { type ThreadListV2Status, } from "./threadListV2"; import { QueuedMessageIcon } from "./queued-message-icon"; +import { shouldRecedeThreadRow } from "./thread-row-emphasis"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; /** @@ -63,14 +64,50 @@ import { ThreadSearchMatchExcerpt } from "./thread-search-match"; // Status hues follow the system-wide convention set by sidebar v1 and the // Live Activity/widgets (amber approval, indigo input, sky working) so a // thread reads the same color everywhere it surfaces. -const STATUS_LABEL_BY_STATUS: Partial< - Record -> = { - approval: { label: "Approval", className: "text-warning-foreground" }, - input: { label: "Input", className: "text-adaptive-indigo-600-300" }, - working: { label: "Working", className: "text-adaptive-sky-600-400" }, - failed: { label: "Failed", className: "text-danger-foreground" }, - limited: { label: "Limited", className: "text-warning-foreground" }, +// Icons match the web sidebar's status glyphs. +interface StatusLabel { + readonly label: string; + readonly icon: AppSymbolName; + readonly className: string; + readonly iconTintClassName: string; +} +const STATUS_LABEL_BY_STATUS: Partial> = { + approval: { + label: "Approval", + icon: "exclamationmark.shield", + className: "text-warning-foreground", + iconTintClassName: "accent-warning-foreground", + }, + input: { + label: "Input", + icon: "questionmark.bubble", + className: "text-adaptive-indigo-600-300", + iconTintClassName: "accent-adaptive-indigo-600-300", + }, + working: { + label: "Working", + icon: "circle.dashed", + className: "text-adaptive-sky-600-400", + iconTintClassName: "accent-adaptive-sky-600-400", + }, + failed: { + label: "Failed", + icon: "exclamationmark.circle", + className: "text-danger-foreground", + iconTintClassName: "accent-danger-foreground", + }, + limited: { + label: "Limited", + icon: "exclamationmark.circle", + className: "text-warning-foreground", + iconTintClassName: "accent-warning-foreground", + }, +}; +const DONE_STATUS_LABEL: StatusLabel = { + label: "Done", + icon: "checkmark.circle", + className: "text-adaptive-emerald-700-300", + iconTintClassName: "accent-adaptive-emerald-700-300", }; // Menus keep lifecycle and title regeneration together. Archive keeps its @@ -593,8 +630,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { // A native /goal keeps the agent going across turns until it is met. (status === "working" && workingLabel !== undefined && thread.goal?.status === "active" ? { ...workingLabel, label: "Goal" } - : workingLabel) ?? - (isUnread ? { label: "Done", className: "text-adaptive-emerald-700-300" } : undefined); + : workingLabel) ?? (isUnread ? DONE_STATUS_LABEL : undefined); + const recede = shouldRecedeThreadRow({ status, selected }); // The timestamp is precomputed on the list item (same stamps the settled // tail sorts by) so a minute tick only re-renders rows that draw it. const timeLabel = props.timeLabel; @@ -959,21 +996,44 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { type="monochrome" /> ) : null} - + + + {statusLabel.label} + + + ) : ( + - {statusLabel?.label ?? timeLabel} - + : rowAppearance.tertiaryForegroundClassName, + )} + > + {timeLabel} + + )} ); + // Background work fades as a whole, status label included, so it takes + // less attention than rows that need a human (input, approval, done). + const fadedCardContent = recede ? ( + {cardContent} + ) : ( + cardContent + ); + const rowContent = (close: () => void) => variant === "card" ? ( {sidebarPane ? ( - cardContent + fadedCardContent ) : ( /* Flat native list rows: no tonal containers — colored status labels and text hierarchy carry state, an inset hairline separates rows. The opaque screen background stays so swipe actions reveal behind the row. */ - {cardContent} + {fadedCardContent} {THREAD_LIST_V2_ROW_DIVIDERS && props.showTrailingDivider !== false ? ( ) : null} diff --git a/apps/mobile/src/features/threads/thread-row-emphasis.test.ts b/apps/mobile/src/features/threads/thread-row-emphasis.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ac97f0781c8e02aa608f804e959812ab03c3981 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-row-emphasis.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { shouldRecedeThreadRow } from "./thread-row-emphasis"; + +describe("shouldRecedeThreadRow", () => { + it("recedes background work", () => { + expect(shouldRecedeThreadRow({ status: "working", selected: false })).toBe(true); + expect(shouldRecedeThreadRow({ status: "waiting", selected: false })).toBe(true); + }); + + it("keeps rows that need a human at full strength", () => { + for (const status of ["approval", "input", "failed", "limited", "ready"] as const) { + expect(shouldRecedeThreadRow({ status, selected: false })).toBe(false); + } + }); + + it("never fades the selected row", () => { + expect(shouldRecedeThreadRow({ status: "working", selected: true })).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-row-emphasis.ts b/apps/mobile/src/features/threads/thread-row-emphasis.ts new file mode 100644 index 0000000000000000000000000000000000000000..a861ef42a4e7621fd130da79137fe866a6c41e32 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-row-emphasis.ts @@ -0,0 +1,13 @@ +import type { ThreadListV2Status } from "./threadListV2"; + +/** + * Mirrors web's recede rule: background work fades so rows that need a + * human (approval, input, failure, unseen completion) stand out. An unseen + * completion cannot pull a still-working thread forward. + */ +export function shouldRecedeThreadRow(input: { + readonly status: ThreadListV2Status; + readonly selected: boolean; +}): boolean { + return !input.selected && (input.status === "working" || input.status === "waiting"); +} -- 2.54.0