diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 1615c7b73..8058320f9 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -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 = ...; 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 ( - - - {completed ? ( - - ) : null} - - {text} - - ); -} - 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 ? ( {t("message.todo.empty")} ) : ( - items.map((item) => ( - - )) + items.map((item) => ) )} diff --git a/packages/app/src/components/task-list-row.test.tsx b/packages/app/src/components/task-list-row.test.tsx new file mode 100644 index 000000000..94ed47bc7 --- /dev/null +++ b/packages/app/src/components/task-list-row.test.tsx @@ -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>) => + React.createElement("div", props, children), + Text: ({ children, style }: React.PropsWithChildren<{ style?: Record[] }>) => { + 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>) => + ({ uniProps, ...props }: Record) => { + const themedProps = (uniProps as (value: typeof theme) => Record)(theme); + return React.createElement(Component, { ...props, ...themedProps }); + }, +})); + +vi.mock("lucide-react-native", () => { + const createIcon = (name: string) => (props: Record) => + 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()); + + 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()); + + 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()); + + 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"); + }); +}); diff --git a/packages/app/src/components/task-list-row.tsx b/packages/app/src/components/task-list-row.tsx new file mode 100644 index 000000000..5dc834b61 --- /dev/null +++ b/packages/app/src/components/task-list-row.tsx @@ -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 ; + } + if (isRunning) { + return ; + } + return ; +} + +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 ( + + + + {text} + + + ); +}); + +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", + }, +})); diff --git a/packages/app/src/composer/task-list/index.tsx b/packages/app/src/composer/task-list/index.tsx index fa44475a0..4a991abdc 100644 --- a/packages/app/src/composer/task-list/index.tsx +++ b/packages/app/src/composer/task-list/index.tsx @@ -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 ; }); -function TaskStatusIcon({ task }: { task: TodoEntry }) { - if (task.completed || task.status === "completed") { - return ; - } - if (task.status === "in_progress") { - return ; - } - return ; -} - 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[] {expanded ? ( - {tasks.map((task, index) => { - const isActive = task.status === "in_progress"; - const text = isActive && task.activeForm ? task.activeForm : task.text; - return ( - - - - {text} - - - ); - })} + {tasks.map((task, index) => ( + + ))} ) : null} @@ -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], }, }));