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 { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
|
import { TaskListRow } from "@/components/task-list-row";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -166,7 +167,6 @@ const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
|
|||||||
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
|
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
|
||||||
|
|
||||||
const ThemedMicVocal = withUnistyles(MicVocal);
|
const ThemedMicVocal = withUnistyles(MicVocal);
|
||||||
const ThemedTodoCheckIcon = withUnistyles(Check);
|
|
||||||
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
|
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
|
||||||
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
|
const ThemedTriangleAlertIcon = withUnistyles(TriangleAlertIcon);
|
||||||
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
|
const ThemedChevronRightIcon = withUnistyles(ChevronRight);
|
||||||
@@ -179,9 +179,6 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
|||||||
const mutedForegroundColorMapping = (theme: Theme) => ({
|
const mutedForegroundColorMapping = (theme: Theme) => ({
|
||||||
color: theme.colors.mutedForeground,
|
color: theme.colors.mutedForeground,
|
||||||
});
|
});
|
||||||
const primaryForegroundColorMapping = (theme: Theme) => ({
|
|
||||||
color: theme.colors.primaryForeground,
|
|
||||||
});
|
|
||||||
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
|
const destructiveColorMapping = (theme: Theme) => ({ color: theme.colors.destructive });
|
||||||
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
|
const WEB_TOOLCALL_SHIMMER_KEYFRAME_CSS = `
|
||||||
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
|
@keyframes ${WEB_TOOLCALL_SHIMMER_ANIMATION_NAME} {
|
||||||
@@ -2239,12 +2236,6 @@ interface TodoListCardProps {
|
|||||||
disableOuterSpacing?: boolean;
|
disableOuterSpacing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TodoListItemRowProps {
|
|
||||||
text: string;
|
|
||||||
completed: boolean;
|
|
||||||
status?: TodoEntry["status"];
|
|
||||||
}
|
|
||||||
|
|
||||||
function taskActivityIcon(activity: TaskActivity) {
|
function taskActivityIcon(activity: TaskActivity) {
|
||||||
switch (activity.type) {
|
switch (activity.type) {
|
||||||
case "added":
|
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) => ({
|
const todoListCardStylesheet = StyleSheet.create((theme) => ({
|
||||||
detailsWrapper: {
|
detailsWrapper: {
|
||||||
padding: theme.spacing[2],
|
padding: theme.spacing[2],
|
||||||
@@ -2295,40 +2258,6 @@ const todoListCardStylesheet = StyleSheet.create((theme) => ({
|
|||||||
list: {
|
list: {
|
||||||
gap: theme.spacing[1],
|
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: {
|
emptyText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
@@ -2366,14 +2295,7 @@ export const TodoListCard = memo(function TodoListCard({
|
|||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<Text style={todoListCardStylesheet.emptyText}>{t("message.todo.empty")}</Text>
|
<Text style={todoListCardStylesheet.emptyText}>{t("message.todo.empty")}</Text>
|
||||||
) : (
|
) : (
|
||||||
items.map((item) => (
|
items.map((item) => <TaskListRow key={item.id ?? item.text} task={item} />)
|
||||||
<TodoListItemRow
|
|
||||||
key={item.id ?? item.text}
|
|
||||||
text={item.text}
|
|
||||||
completed={item.completed}
|
|
||||||
status={item.status}
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</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 { memo, useCallback, useMemo, useState } from "react";
|
||||||
import { Text, View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { Check, ChevronDown, ChevronRight, Circle, CircleDot } from "lucide-react-native";
|
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { Button } from "@/components/ui/button";
|
||||||
|
import { TaskListRow } from "@/components/task-list-row";
|
||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import type { Theme } from "@/styles/theme";
|
|
||||||
import type { TodoEntry } from "@/types/stream";
|
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({
|
export const AgentTaskList = memo(function AgentTaskList({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
@@ -28,16 +21,6 @@ export const AgentTaskList = memo(function AgentTaskList({
|
|||||||
return <TaskListCard tasks={tasks} />;
|
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 TaskListCard = memo(function TaskListCard({ tasks }: { tasks: TodoEntry[] }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
@@ -83,25 +66,9 @@ const TaskListCard = memo(function TaskListCard({ tasks }: { tasks: TodoEntry[]
|
|||||||
</Button>
|
</Button>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<View style={styles.list}>
|
<View style={styles.list}>
|
||||||
{tasks.map((task, index) => {
|
{tasks.map((task, index) => (
|
||||||
const isActive = task.status === "in_progress";
|
<TaskListRow key={task.id ?? `${index}:${task.text}`} task={task} />
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -142,22 +109,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
list: {
|
list: {
|
||||||
borderTopWidth: theme.borderWidth[1],
|
borderTopWidth: theme.borderWidth[1],
|
||||||
borderTopColor: theme.colors.border,
|
borderTopColor: theme.colors.border,
|
||||||
paddingTop: theme.spacing[1],
|
|
||||||
},
|
|
||||||
row: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[2],
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
paddingVertical: theme.spacing[1],
|
paddingTop: theme.spacing[2],
|
||||||
},
|
gap: theme.spacing[1],
|
||||||
taskText: {
|
|
||||||
flex: 1,
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
|
||||||
completedText: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
textDecorationLine: "line-through",
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user