Compare commits

...

2 Commits

Author SHA1 Message Date
openhands 31341cc7f1 Add debugging and data-testid for tasks tab visibility troubleshooting
- Add console.log statements to help debug tab rendering
- Add data-testid='tasks-tab' to make tasks tab easier to identify
- Update ConversationTabNav to accept data-testid prop
2025-09-19 20:26:01 +00:00
openhands aaa2dbe45e Fix Unicode regex pattern in task parsing
Add 'u' flag to regex to properly handle Unicode emoji characters in task status parsing.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-09-19 20:03:32 +00:00
8 changed files with 260 additions and 1 deletions
@@ -16,6 +16,7 @@ const BrowserTab = lazy(() => import("#/routes/browser-tab"));
const JupyterTab = lazy(() => import("#/routes/jupyter-tab"));
const ServedTab = lazy(() => import("#/routes/served-tab"));
const VSCodeTab = lazy(() => import("#/routes/vscode-tab"));
const TasksTab = lazy(() => import("#/routes/tasks-tab"));
export function ConversationTabContent() {
const selectedTab = useSelector(
@@ -34,6 +35,7 @@ export function ConversationTabContent() {
const isServedActive = selectedTab === "served";
const isVSCodeActive = selectedTab === "vscode";
const isTerminalActive = selectedTab === "terminal";
const isTasksActive = selectedTab === "tasks";
// Define tab configurations
const tabs = [
@@ -55,6 +57,7 @@ export function ConversationTabContent() {
component: Terminal,
isActive: isTerminalActive,
},
{ key: "tasks", component: TasksTab, isActive: isTasksActive },
];
const conversationTabTitle = useMemo(() => {
@@ -76,6 +79,9 @@ export function ConversationTabContent() {
if (isTerminalActive) {
return t(I18nKey.COMMON$TERMINAL);
}
if (isTasksActive) {
return t(I18nKey.COMMON$TASKS);
}
return "";
}, [
isEditorActive,
@@ -84,6 +90,7 @@ export function ConversationTabContent() {
isServedActive,
isVSCodeActive,
isTerminalActive,
isTasksActive,
]);
if (shouldShownAgentLoading) {
@@ -5,12 +5,14 @@ type ConversationTabNavProps = {
icon: ComponentType<{ className: string }>;
onClick(): void;
isActive?: boolean;
"data-testid"?: string;
};
export function ConversationTabNav({
icon: Icon,
onClick,
isActive,
"data-testid": dataTestId,
}: ConversationTabNavProps) {
return (
<button
@@ -18,6 +20,7 @@ export function ConversationTabNav({
onClick={() => {
onClick();
}}
data-testid={dataTestId}
className={cn(
"p-1 rounded-md cursor-pointer",
"text-[#9299AA] bg-[#0D0F11]",
@@ -8,6 +8,7 @@ import GlobeIcon from "#/icons/globe.svg?react";
import ServerIcon from "#/icons/server.svg?react";
import GitChanges from "#/icons/git_changes.svg?react";
import VSCodeIcon from "#/icons/vscode.svg?react";
import ListIcon from "#/icons/list.svg?react";
import { cn } from "#/utils/utils";
import { ConversationTabNav } from "./conversation-tab-nav";
import { ChatActionTooltip } from "../../chat/chat-action-tooltip";
@@ -130,8 +131,19 @@ export function ConversationTabs() {
tooltipContent: t(I18nKey.COMMON$BROWSER),
tooltipAriaLabel: t(I18nKey.COMMON$BROWSER),
},
{
isActive: isTabActive("tasks"),
icon: ListIcon,
onClick: () => onTabSelected("tasks"),
tooltipContent: t(I18nKey.COMMON$TASKS),
tooltipAriaLabel: t(I18nKey.COMMON$TASKS),
},
];
// Debug logging to help troubleshoot tab visibility
console.log("ConversationTabs: Rendering", tabs.length, "tabs");
console.log("ConversationTabs: Tasks tab config:", tabs[tabs.length - 1]);
return (
<div
className={cn(
@@ -153,6 +165,7 @@ export function ConversationTabs() {
icon={icon}
onClick={onClick}
isActive={isActive}
data-testid={index === tabs.length - 1 ? "tasks-tab" : undefined}
/>
</ChatActionTooltip>
),
+100
View File
@@ -0,0 +1,100 @@
import { useQuery } from "@tanstack/react-query";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
interface Task {
id: string;
title: string;
status: "todo" | "in_progress" | "done";
notes?: string;
}
const parseTasksFromMarkdown = (content: string): Task[] => {
const tasks: Task[] = [];
const lines = content.split("\n");
let currentTask: Partial<Task> | null = null;
for (const line of lines) {
// Match task lines like: "1. ✅ Add 'tasks' to ConversationTab type in conversation-slice.tsx"
const taskMatch = line.match(/^(\d+)\.\s*([🔄])\s*(.+)$/u);
if (taskMatch) {
// Save previous task if exists
if (
currentTask &&
currentTask.id &&
currentTask.title &&
currentTask.status
) {
tasks.push(currentTask as Task);
}
const [, id, statusIcon, title] = taskMatch;
let status: Task["status"] = "todo";
// Determine status from emoji
if (statusIcon === "✅") {
status = "done";
} else if (statusIcon === "🔄") {
status = "in_progress";
} else if (statusIcon === "⏳") {
status = "todo";
}
currentTask = {
id,
title: title.trim(),
status,
notes: "",
};
} else if (currentTask && line.trim() && !line.startsWith("#")) {
// This is likely a notes line for the current task
if (currentTask.notes) {
currentTask.notes += ` ${line.trim()}`;
} else {
currentTask.notes = line.trim();
}
}
}
// Don't forget the last task
if (
currentTask &&
currentTask.id &&
currentTask.title &&
currentTask.status
) {
tasks.push(currentTask as Task);
}
return tasks;
};
export const useGetTasks = () => {
const { conversationId } = useConversationId();
const runtimeIsReady = useRuntimeIsReady();
return useQuery({
queryKey: ["tasks", conversationId],
queryFn: async () => {
try {
const content = await ConversationService.getFile(
conversationId,
"TASKS.md",
);
return parseTasksFromMarkdown(content);
} catch (error) {
// If TASKS.md doesn't exist, return empty array
return [];
}
},
retry: false,
staleTime: 1000 * 30, // 30 seconds
gcTime: 1000 * 60 * 5, // 5 minutes
enabled: runtimeIsReady && !!conversationId,
meta: {
disableToast: true,
},
});
};
+3
View File
@@ -890,6 +890,7 @@ export enum I18nKey {
COMMON$START_CONVERSATION = "COMMON$START_CONVERSATION",
COMMON$STOP_SERVER = "COMMON$STOP_SERVER",
COMMON$TERMINAL = "COMMON$TERMINAL",
COMMON$TASKS = "COMMON$TASKS",
COMMON$UNKNOWN = "COMMON$UNKNOWN",
COMMON$USER_SETTINGS = "COMMON$USER_SETTINGS",
COMMON$VIEW = "COMMON$VIEW",
@@ -910,4 +911,6 @@ export enum I18nKey {
COMMON$STOP_RUNTIME = "COMMON$STOP_RUNTIME",
COMMON$START_RUNTIME = "COMMON$START_RUNTIME",
COMMON$JUPYTER_EMPTY_MESSAGE = "COMMON$JUPYTER_EMPTY_MESSAGE",
COMMON$ERROR_LOADING_TASKS = "COMMON$ERROR_LOADING_TASKS",
COMMON$NO_TASKS_AVAILABLE = "COMMON$NO_TASKS_AVAILABLE",
}
+48
View File
@@ -14239,6 +14239,22 @@
"de": "Terminal",
"uk": "Термінал"
},
"COMMON$TASKS": {
"en": "Task List",
"ja": "タスクリスト",
"zh-CN": "任务列表",
"zh-TW": "任務列表",
"ko-KR": "작업 목록",
"no": "Oppgaveliste",
"it": "Elenco attività",
"pt": "Lista de tarefas",
"es": "Lista de tareas",
"ar": "قائمة المهام",
"fr": "Liste des tâches",
"tr": "Görev Listesi",
"de": "Aufgabenliste",
"uk": "Список завдань"
},
"COMMON$UNKNOWN": {
"en": "Unknown",
"ja": "不明",
@@ -14558,5 +14574,37 @@
"tr": "Jupyter defteriniz boş. Gösterilecek hücre yok.",
"de": "Ihr Jupyter-Notebook ist leer. Keine Zellen zum Anzeigen.",
"uk": "Ваш Jupyter-ноутбук порожній. Немає клітинок для відображення."
},
"COMMON$ERROR_LOADING_TASKS": {
"en": "Error loading tasks",
"ja": "タスクの読み込み中にエラーが発生しました",
"zh-CN": "加载任务时出错",
"zh-TW": "載入任務時發生錯誤",
"ko-KR": "작업을 불러오는 중 오류가 발생했습니다",
"no": "Feil ved lasting av oppgaver",
"it": "Errore nel caricamento delle attività",
"pt": "Erro ao carregar tarefas",
"es": "Error al cargar tareas",
"ar": "خطأ في تحميل المهام",
"fr": "Erreur lors du chargement des tâches",
"tr": "Görevler yüklenirken hata oluştu",
"de": "Fehler beim Laden der Aufgaben",
"uk": "Помилка завантаження завдань"
},
"COMMON$NO_TASKS_AVAILABLE": {
"en": "No tasks available",
"ja": "利用可能なタスクがありません",
"zh-CN": "没有可用的任务",
"zh-TW": "沒有可用的任務",
"ko-KR": "사용 가능한 작업이 없습니다",
"no": "Ingen oppgaver tilgjengelig",
"it": "Nessuna attività disponibile",
"pt": "Nenhuma tarefa disponível",
"es": "No hay tareas disponibles",
"ar": "لا توجد مهام متاحة",
"fr": "Aucune tâche disponible",
"tr": "Mevcut görev yok",
"de": "Keine Aufgaben verfügbar",
"uk": "Немає доступних завдань"
}
}
+84
View File
@@ -0,0 +1,84 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { TaskListSection } from "#/components/features/chat/task-tracking/task-list-section";
import { useGetTasks } from "#/hooks/query/use-get-tasks";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { RandomTip } from "#/components/features/tips/random-tip";
function StatusMessage({ children }: React.PropsWithChildren) {
return (
<div className="w-full h-full flex flex-col items-center text-center justify-center text-2xl text-tertiary-light">
{children}
</div>
);
}
function TasksTab() {
const { t } = useTranslation();
const {
data: tasks,
isSuccess,
isError,
error,
isLoading: loadingTasks,
} = useGetTasks();
const [statusMessage, setStatusMessage] = React.useState<string[] | null>(
null,
);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const runtimeIsActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
React.useEffect(() => {
if (!runtimeIsActive) {
setStatusMessage([I18nKey.DIFF_VIEWER$WAITING_FOR_RUNTIME]);
} else if (error) {
setStatusMessage([I18nKey.COMMON$ERROR_LOADING_TASKS]);
} else if (loadingTasks) {
setStatusMessage([I18nKey.HOME$LOADING]);
} else {
setStatusMessage(null);
}
}, [runtimeIsActive, loadingTasks, error, setStatusMessage]);
return (
<main className="h-full overflow-y-scroll p-4 md:pr-1.5 gap-3 flex flex-col items-center custom-scrollbar-always">
{!isSuccess || !tasks || tasks.length === 0 ? (
<div className="relative flex h-full w-full items-center">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2">
{statusMessage && (
<StatusMessage>
{statusMessage.map((msg) => (
<span key={msg}>{t(msg)}</span>
))}
</StatusMessage>
)}
{!statusMessage && isSuccess && tasks && tasks.length === 0 && (
<StatusMessage>
<span>{t(I18nKey.COMMON$NO_TASKS_AVAILABLE)}</span>
</StatusMessage>
)}
</div>
<div className="absolute inset-x-0 bottom-0">
{!isError && tasks?.length === 0 && (
<div className="max-w-2xl mb-4 text-m bg-tertiary rounded-xl p-4 text-left mx-auto">
<RandomTip />
</div>
)}
</div>
</div>
) : (
<div className="w-full max-w-4xl">
<TaskListSection taskList={tasks} />
</div>
)}
</main>
);
}
export default TasksTab;
+2 -1
View File
@@ -6,7 +6,8 @@ export type ConversationTab =
| "jupyter"
| "served"
| "vscode"
| "terminal";
| "terminal"
| "tasks";
export interface IMessageToSend {
text: string;