Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/mobile/src/components/AppSymbol.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
116 changes: 92 additions & 24 deletions apps/mobile/src/features/threads/thread-list-v2-items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";

/**
Expand All @@ -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<ThreadListV2Status, { label: string; className: string }>
> = {
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<Record<ThreadListV2Status, StatusLabel>> = {
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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -959,21 +996,44 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
type="monochrome"
/>
) : null}
<Text
className={cn(
"text-xs tabular-nums",
statusLabel?.className ??
(selected
{statusLabel ? (
<View className="flex-row items-center gap-1">
<SymbolView
name={statusLabel.icon}
size={13}
tintColorClassName={
selected ? selectedThreadRowColors.iconTintClassName : statusLabel.iconTintClassName
}
type="monochrome"
weight="semibold"
/>
<Text
className={cn(
"text-xs font-t3-bold",
selected ? selectedThreadRowColors.foregroundClassName : statusLabel.className,
)}
>
{statusLabel.label}
</Text>
</View>
) : (
<Text
className={cn(
"text-xs tabular-nums",
selected
? selectedThreadRowColors.foregroundClassName
: rowAppearance.tertiaryForegroundClassName),
)}
>
{statusLabel?.label ?? timeLabel}
</Text>
: rowAppearance.tertiaryForegroundClassName,
)}
>
{timeLabel}
</Text>
)}
</View>
<Text
className={cn(
"mt-1 text-base font-t3-medium",
"mt-1 text-base",
// Background work recedes to regular weight, matching web.
!recede && "font-t3-medium",
selected
? selectedThreadRowColors.foregroundClassName
: rowAppearance.foregroundClassName,
Expand Down Expand Up @@ -1115,6 +1175,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
</>
);

// 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 ? (
<View style={{ opacity: 0.55 }}>{cardContent}</View>
) : (
cardContent
);

const rowContent = (close: () => void) =>
variant === "card" ? (
<RowPressable
Expand All @@ -1135,14 +1203,14 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
style={rowAppearance.cardStyle}
>
{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. */
<View>
<View className={THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME}>{cardContent}</View>
<View className={THREAD_LIST_V2_ROW_CONTENT_CLASS_NAME}>{fadedCardContent}</View>
{THREAD_LIST_V2_ROW_DIVIDERS && props.showTrailingDivider !== false ? (
<View className="ml-5 h-px bg-border-subtle" />
) : null}
Expand Down
20 changes: 20 additions & 0 deletions apps/mobile/src/features/threads/thread-row-emphasis.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
13 changes: 13 additions & 0 deletions apps/mobile/src/features/threads/thread-row-emphasis.ts
Original file line number Diff line number Diff line change
@@ -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");
}
Loading