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],
},
}));