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:
@@ -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",
|
||||
},
|
||||
}));
|
||||
@@ -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],
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user