feat(app): unify task list status styling

Use one task-row presentation across message and composer lists so running, pending, and completed states keep the same hierarchy and status language.
This commit is contained in:
Mohamed Boudra
2026-08-13 18:52:15 +02:00
parent 9c195feb9b
commit 0f27230746
4 changed files with 188 additions and 136 deletions
+2 -80
View File
@@ -1,4 +1,5 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { TaskListRow } from "@/components/task-list-row";
import {
View,
Text,
@@ -166,7 +167,6 @@ const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
const ThemedMicVocal = withUnistyles(MicVocal);
const ThemedTodoCheckIcon = withUnistyles(Check);
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
@@ -179,9 +179,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
const mutedForegroundColorMapping = (theme: Theme) => ({
color: theme.colors.mutedForeground,
});
const primaryForegroundColorMapping = (theme: Theme) => ({
color: theme.colors.primaryForeground,
});
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
@@ -2239,12 +2236,6 @@ interface TodoListCardProps {
disableOuterSpacing?: boolean;
}
interface TodoListItemRowProps {
text: string;
completed: boolean;
status?: TodoEntry["status"];
}
function taskActivityIcon(activity: TaskActivity) {
switch (activity.type) {
case "added":
@@ -2260,34 +2251,6 @@ function taskActivityIcon(activity: TaskActivity) {
}
}
function TodoListItemRow({ text, completed, status }: TodoListItemRowProps) {
const badgeStyle = useMemo(
() => [
todoListCardStylesheet.radioBadge,
completed
? todoListCardStylesheet.radioBadgeComplete
: todoListCardStylesheet.radioBadgeIncomplete,
],
[completed],
);
const textStyle = useMemo(
() => [todoListCardStylesheet.itemText, completed && todoListCardStylesheet.itemTextCompleted],
[completed],
);
return (
<View style={todoListCardStylesheet.itemRow}>
<View
style={[badgeStyle, status === "in_progress" && todoListCardStylesheet.radioBadgeActive]}
>
{completed ? (
<ThemedTodoCheckIcon size={12} uniProps={primaryForegroundColorMapping} />
) : null}
</View>
<Text style={textStyle}>{text}</Text>
</View>
);
}
const todoListCardStylesheet = StyleSheet.create((theme) => ({
detailsWrapper: {
padding: theme.spacing[2],
@@ -2295,40 +2258,6 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({
list: {
gap: theme.spacing[1],
},
itemRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
radioBadge: {
width: 16,
height: 16,
borderRadius: 8,
backgroundColor: theme.colors.foregroundMuted,
alignItems: "center",
justifyContent: "center",
},
radioBadgeIncomplete: {
opacity: 0.55,
},
radioBadgeComplete: {
opacity: 0.95,
},
radioBadgeActive: {
borderWidth: 2,
borderColor: theme.colors.primary,
backgroundColor: "transparent",
opacity: 1,
},
itemText: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
itemTextCompleted: {
color: theme.colors.foregroundMuted,
textDecorationLine: "line-through",
},
emptyText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base,
@@ -2366,14 +2295,7 @@ export const TodoListCard = memo(function TodoListCard({
{items.length === 0 ? (
<Text style={todoListCardStylesheet.emptyText}>{t("message.todo.empty")}</Text>
) : (
items.map((item) => (
<TodoListItemRow
key={item.id ?? item.text}
text={item.text}
completed={item.completed}
status={item.status}
/>
))
items.map((item) => <TaskListRow key={item.id ?? item.text} task={item} />)
)}
</View>
</View>
@@ -0,0 +1,115 @@
/**
* @vitest-environment jsdom
*/
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { theme } = vi.hoisted(() => ({
theme: {
spacing: { 2: 8 },
fontSize: { sm: 13 },
colors: {
foreground: "#foreground",
foregroundMuted: "#muted",
foregroundExtraMuted: "#extra-muted",
statusDotRunning: "#running",
},
},
}));
vi.mock("react-native", () => ({
View: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) =>
React.createElement("div", props, children),
Text: ({ children, style }: React.PropsWithChildren<{ style?: Record<string, unknown>[] }>) => {
const resolvedStyle = Object.assign({}, ...(style ?? []).filter(Boolean));
return React.createElement(
"span",
{
"data-color": resolvedStyle.color,
"data-decoration": resolvedStyle.textDecorationLine,
},
children,
);
},
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: (value: typeof theme) => unknown) => factory(theme),
},
withUnistyles:
(Component: React.ComponentType<Record<string, unknown>>) =>
({ uniProps, ...props }: Record<string, unknown>) => {
const themedProps = (uniProps as (value: typeof theme) => Record<string, unknown>)(theme);
return React.createElement(Component, { ...props, ...themedProps });
},
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
Circle: createIcon("Circle"),
CircleCheck: createIcon("CircleCheck"),
CircleDot: createIcon("CircleDot"),
};
});
vi.stubGlobal("React", React);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
import { TaskListRow } from "./task-list-row";
const pendingTask = { text: "Queued", completed: false } as const;
const runningTask = {
text: "Run checks",
activeForm: "Running checks",
completed: false,
status: "in_progress",
} as const;
const completedTask = { text: "Finished", completed: true, status: "completed" } as const;
describe("TaskListRow", () => {
let container: HTMLElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it("keeps pending tasks muted", () => {
act(() => root.render(<TaskListRow task={pendingTask} />));
expect(container.querySelector('[data-icon="Circle"]')?.getAttribute("color")).toBe("#muted");
expect(container.querySelector("[data-color]")?.getAttribute("data-color")).toBe("#muted");
});
it("foregrounds only the running task with the running blue circle dot", () => {
act(() => root.render(<TaskListRow task={runningTask} />));
expect(container.textContent).toBe("Running checks");
expect(container.querySelector('[data-icon="CircleDot"]')?.getAttribute("color")).toBe(
"#running",
);
expect(container.querySelector("[data-color]")?.getAttribute("data-color")).toBe("#foreground");
});
it("extra-mutes completed tasks with a circle check", () => {
act(() => root.render(<TaskListRow task={completedTask} />));
expect(container.querySelector('[data-icon="CircleCheck"]')?.getAttribute("color")).toBe(
"#extra-muted",
);
const text = container.querySelector("[data-color]");
expect(text?.getAttribute("data-color")).toBe("#extra-muted");
expect(text?.getAttribute("data-decoration")).toBe("line-through");
});
});
@@ -0,0 +1,62 @@
import { Circle, CircleCheck, CircleDot } from "lucide-react-native";
import { memo } from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import type { TodoEntry } from "@/types/stream";
const ThemedCircle = withUnistyles(Circle);
const ThemedCircleCheck = withUnistyles(CircleCheck);
const ThemedCircleDot = withUnistyles(CircleDot);
const extraMutedIcon = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
const mutedIcon = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const runningIcon = (theme: Theme) => ({ color: theme.colors.statusDotRunning });
function TaskStatusIcon({ isCompleted, isRunning }: { isCompleted: boolean; isRunning: boolean }) {
if (isCompleted) {
return <ThemedCircleCheck size={16} uniProps={extraMutedIcon} />;
}
if (isRunning) {
return <ThemedCircleDot size={16} uniProps={runningIcon} />;
}
return <ThemedCircle size={16} uniProps={mutedIcon} />;
}
export const TaskListRow = memo(function TaskListRow({ task }: { task: TodoEntry }) {
const isCompleted = task.completed || task.status === "completed";
const isRunning = !isCompleted && task.status === "in_progress";
const text = isRunning && task.activeForm ? task.activeForm : task.text;
return (
<View style={styles.row} accessibilityLabel={text}>
<TaskStatusIcon isCompleted={isCompleted} isRunning={isRunning} />
<Text
numberOfLines={1}
style={[styles.text, isRunning && styles.runningText, isCompleted && styles.completedText]}
>
{text}
</Text>
</View>
);
});
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
text: {
flex: 1,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
},
runningText: {
color: theme.colors.foreground,
},
completedText: {
color: theme.colors.foregroundExtraMuted,
textDecorationLine: "line-through",
},
}));
+9 -56
View File
@@ -1,21 +1,14 @@
import { memo, useCallback, useMemo, useState } from "react";
import { Text, View } from "react-native";
import { Check, ChevronDown, ChevronRight, Circle, CircleDot } from "lucide-react-native";
import { View } from "react-native";
import { ChevronDown, ChevronRight } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { TaskListRow } from "@/components/task-list-row";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { useSessionStore } from "@/stores/session-store";
import type { Theme } from "@/styles/theme";
import type { TodoEntry } from "@/types/stream";
const ThemedCheck = withUnistyles(Check);
const ThemedCircle = withUnistyles(Circle);
const ThemedCircleDot = withUnistyles(CircleDot);
const mutedIcon = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const activeIcon = (theme: Theme) => ({ color: theme.colors.statusWarning });
const completedIcon = (theme: Theme) => ({ color: theme.colors.statusSuccess });
export const AgentTaskList = memo(function AgentTaskList({
serverId,
agentId,
@@ -28,16 +21,6 @@ export const AgentTaskList = memo(function AgentTaskList({
return <TaskListCard tasks={tasks} />;
});
function TaskStatusIcon({ task }: { task: TodoEntry }) {
if (task.completed || task.status === "completed") {
return <ThemedCheck size={15} uniProps={completedIcon} />;
}
if (task.status === "in_progress") {
return <ThemedCircleDot size={15} uniProps={activeIcon} />;
}
return <ThemedCircle size={15} uniProps={mutedIcon} />;
}
const TaskListCard = memo(function TaskListCard({ tasks }: { tasks: TodoEntry[] }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
@@ -83,25 +66,9 @@ const TaskListCard = memo(function TaskListCard({ tasks }: { tasks: TodoEntry[]
</Button>
{expanded ? (
<View style={styles.list}>
{tasks.map((task, index) => {
const isActive = task.status === "in_progress";
const text = isActive && task.activeForm ? task.activeForm : task.text;
return (
<View
key={task.id ?? `${index}:${task.text}`}
style={styles.row}
accessibilityLabel={text}
>
<TaskStatusIcon task={task} />
<Text
numberOfLines={1}
style={[styles.taskText, task.completed && styles.completedText]}
>
{text}
</Text>
</View>
);
})}
{tasks.map((task, index) => (
<TaskListRow key={task.id ?? `${index}:${task.text}`} task={task} />
))}
</View>
) : null}
</View>
@@ -142,22 +109,8 @@ const styles = StyleSheet.create((theme) => ({
list: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
paddingTop: theme.spacing[1],
},
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
},
taskText: {
flex: 1,
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
},
completedText: {
color: theme.colors.foregroundMuted,
textDecorationLine: "line-through",
paddingTop: theme.spacing[2],
gap: theme.spacing[1],
},
}));