mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 13:47:19 +08:00
feat(frontend): create a separate UI tab for monitoring tasks (#13065)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
This commit is contained in:
@@ -84,12 +84,12 @@ describe("TaskTrackingObservationContent", () => {
|
||||
expect(taskItems).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("displays task IDs and notes", () => {
|
||||
it("does not display task IDs but displays notes", () => {
|
||||
render(<TaskTrackingObservationContent event={mockEvent} />);
|
||||
|
||||
expect(screen.getByText("ID: task-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("ID: task-2")).toBeInTheDocument();
|
||||
expect(screen.getByText("ID: task-3")).toBeInTheDocument();
|
||||
expect(screen.queryByText("ID: task-1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("ID: task-2")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("ID: task-3")).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Notes: This is a test task")).toBeInTheDocument();
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { ConversationTabsContextMenu } from "#/components/features/conversation/conversation-tabs/conversation-tabs-context-menu";
|
||||
|
||||
const CONVERSATION_ID = "conv-abc123";
|
||||
|
||||
vi.mock("#/hooks/use-conversation-id", () => ({
|
||||
useConversationId: () => ({ conversationId: CONVERSATION_ID }),
|
||||
}));
|
||||
|
||||
let mockHasTaskList = false;
|
||||
vi.mock("#/hooks/use-task-list", () => ({
|
||||
useTaskList: () => ({
|
||||
hasTaskList: mockHasTaskList,
|
||||
taskList: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("ConversationTabsContextMenu", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
mockHasTaskList = false;
|
||||
});
|
||||
|
||||
it("should render nothing when isOpen is false", () => {
|
||||
const { container } = render(
|
||||
<ConversationTabsContextMenu isOpen={false} onClose={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("should render all default tabs when open", () => {
|
||||
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
const expectedTabs = [
|
||||
"COMMON$PLANNER",
|
||||
"COMMON$CHANGES",
|
||||
"COMMON$CODE",
|
||||
"COMMON$TERMINAL",
|
||||
"COMMON$APP",
|
||||
"COMMON$BROWSER",
|
||||
];
|
||||
for (const tab of expectedTabs) {
|
||||
expect(screen.getByText(tab)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("should re-pin a tab when clicking an unpinned tab", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
const terminalItem = screen.getByText("COMMON$TERMINAL");
|
||||
|
||||
// Unpin
|
||||
await user.click(terminalItem);
|
||||
let storedState = JSON.parse(
|
||||
localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!,
|
||||
);
|
||||
expect(storedState.unpinnedTabs).toContain("terminal");
|
||||
|
||||
// Re-pin
|
||||
await user.click(terminalItem);
|
||||
storedState = JSON.parse(
|
||||
localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!,
|
||||
);
|
||||
expect(storedState.unpinnedTabs).not.toContain("terminal");
|
||||
});
|
||||
|
||||
describe("with tasklist", () => {
|
||||
beforeEach(() => {
|
||||
mockHasTaskList = true;
|
||||
});
|
||||
|
||||
it("should show tasklist in context menu when hasTaskList is true", () => {
|
||||
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("COMMON$TASK_LIST")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs";
|
||||
import { ConversationTabsContextMenu } from "#/components/features/conversation/conversation-tabs/conversation-tabs-context-menu";
|
||||
import { useConversationStore } from "#/stores/conversation-store";
|
||||
|
||||
const TASK_CONVERSATION_ID = "task-ec03fb2ab8604517b24af632b058c2fd";
|
||||
@@ -16,6 +15,14 @@ vi.mock("#/hooks/use-conversation-id", () => ({
|
||||
useConversationId: () => ({ conversationId: mockConversationId }),
|
||||
}));
|
||||
|
||||
let mockHasTaskList = false;
|
||||
vi.mock("#/hooks/use-task-list", () => ({
|
||||
useTaskList: () => ({
|
||||
hasTaskList: mockHasTaskList,
|
||||
taskList: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
const createWrapper = (conversationId: string) => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<MemoryRouter initialEntries={[`/conversations/${conversationId}`]}>
|
||||
@@ -31,6 +38,7 @@ describe("ConversationTabs localStorage behavior", () => {
|
||||
localStorage.clear();
|
||||
vi.resetAllMocks();
|
||||
mockConversationId = TASK_CONVERSATION_ID;
|
||||
mockHasTaskList = false;
|
||||
useConversationStore.setState({
|
||||
selectedTab: null,
|
||||
isRightPanelShown: false,
|
||||
@@ -71,47 +79,6 @@ describe("ConversationTabs localStorage behavior", () => {
|
||||
expect(parsed).toHaveProperty("rightPanelShown");
|
||||
expect(parsed).toHaveProperty("unpinnedTabs");
|
||||
});
|
||||
|
||||
it("should store unpinned tabs in consolidated key via context menu", async () => {
|
||||
mockConversationId = REAL_CONVERSATION_ID;
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
|
||||
|
||||
const terminalItem = screen.getByText("COMMON$TERMINAL");
|
||||
await user.click(terminalItem);
|
||||
|
||||
const consolidatedKey = `conversation-state-${REAL_CONVERSATION_ID}`;
|
||||
const storedState = localStorage.getItem(consolidatedKey);
|
||||
expect(storedState).not.toBeNull();
|
||||
|
||||
const parsed = JSON.parse(storedState!);
|
||||
expect(parsed.unpinnedTabs).toContain("terminal");
|
||||
});
|
||||
|
||||
it("should hide a tab after unpinning it from context menu", async () => {
|
||||
mockConversationId = REAL_CONVERSATION_ID;
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<>
|
||||
<ConversationTabs />
|
||||
<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />
|
||||
</>,
|
||||
{ wrapper: createWrapper(REAL_CONVERSATION_ID) },
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("conversation-tab-terminal"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const terminalItem = screen.getByText("COMMON$TERMINAL");
|
||||
await user.click(terminalItem);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("conversation-tab-terminal"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hook integration", () => {
|
||||
@@ -205,4 +172,37 @@ describe("ConversationTabs localStorage behavior", () => {
|
||||
expect(storedState.selectedTab).toBe("browser");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tasklist tab", () => {
|
||||
beforeEach(() => {
|
||||
mockConversationId = REAL_CONVERSATION_ID;
|
||||
mockHasTaskList = true;
|
||||
});
|
||||
|
||||
it("should show tasklist tab when hasTaskList is true", () => {
|
||||
render(<ConversationTabs />, {
|
||||
wrapper: createWrapper(REAL_CONVERSATION_ID),
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId("conversation-tab-tasklist"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should select tasklist tab when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ConversationTabs />, {
|
||||
wrapper: createWrapper(REAL_CONVERSATION_ID),
|
||||
});
|
||||
|
||||
const tasklistTab = screen.getByTestId("conversation-tab-tasklist");
|
||||
await user.click(tasklistTab);
|
||||
|
||||
const { selectedTab, hasRightPanelToggled } =
|
||||
useConversationStore.getState();
|
||||
expect(selectedTab).toBe("tasklist");
|
||||
expect(hasRightPanelToggled).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
279
frontend/__tests__/hooks/use-task-list.test.ts
Normal file
279
frontend/__tests__/hooks/use-task-list.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useTaskList } from "#/hooks/use-task-list";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import type { OHEvent } from "#/stores/use-event-store";
|
||||
import type { TaskTrackingObservation } from "#/types/core/observations";
|
||||
|
||||
function createV0TaskTrackingObservation(
|
||||
id: number,
|
||||
command: string,
|
||||
taskList: TaskTrackingObservation["extras"]["task_list"],
|
||||
): TaskTrackingObservation {
|
||||
return {
|
||||
id,
|
||||
source: "agent",
|
||||
observation: "task_tracking",
|
||||
message: "Task tracking update",
|
||||
timestamp: `2025-07-01T00:00:0${id}Z`,
|
||||
cause: 0,
|
||||
content: "",
|
||||
extras: {
|
||||
command,
|
||||
task_list: taskList,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createV1TaskTrackerObservation(
|
||||
id: string,
|
||||
command: string,
|
||||
taskList: Array<{
|
||||
title: string;
|
||||
notes: string;
|
||||
status: "todo" | "in_progress" | "done";
|
||||
}>,
|
||||
): OHEvent {
|
||||
return {
|
||||
id,
|
||||
timestamp: `2025-07-01T00:00:0${id}Z`,
|
||||
source: "environment",
|
||||
tool_name: "task_tracker",
|
||||
tool_call_id: `call_${id}`,
|
||||
action_id: `action_${id}`,
|
||||
observation: {
|
||||
kind: "TaskTrackerObservation",
|
||||
content: "Task list updated",
|
||||
command,
|
||||
task_list: taskList,
|
||||
},
|
||||
} as unknown as OHEvent;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useEventStore.setState({
|
||||
events: [],
|
||||
eventIds: new Set(),
|
||||
uiEvents: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTaskList", () => {
|
||||
it("returns empty taskList and hasTaskList=false when no events exist", () => {
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual([]);
|
||||
expect(result.current.hasTaskList).toBe(false);
|
||||
});
|
||||
|
||||
it("returns empty taskList when no task tracking observations exist", () => {
|
||||
useEventStore.setState({
|
||||
events: [
|
||||
{
|
||||
id: 1,
|
||||
source: "user",
|
||||
action: "message",
|
||||
args: { content: "Hello", image_urls: [], file_urls: [] },
|
||||
message: "Hello",
|
||||
timestamp: "2025-07-01T00:00:01Z",
|
||||
},
|
||||
],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual([]);
|
||||
expect(result.current.hasTaskList).toBe(false);
|
||||
});
|
||||
|
||||
describe("v0 events", () => {
|
||||
it('returns the task list from a TaskTrackingObservation with command="plan"', () => {
|
||||
const tasks = [
|
||||
{ id: "1", title: "First task", status: "todo" as const },
|
||||
{ id: "2", title: "Second task", status: "in_progress" as const },
|
||||
];
|
||||
const event = createV0TaskTrackingObservation(1, "plan", tasks);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [event],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [event],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual(tasks);
|
||||
expect(result.current.hasTaskList).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores TaskTrackingObservation events with command !== "plan"', () => {
|
||||
const tasks = [{ id: "1", title: "First task", status: "todo" as const }];
|
||||
const event = createV0TaskTrackingObservation(1, "update", tasks);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [event],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [event],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual([]);
|
||||
expect(result.current.hasTaskList).toBe(false);
|
||||
});
|
||||
|
||||
it("returns the latest task list when multiple plan events exist", () => {
|
||||
const earlyTasks = [
|
||||
{ id: "1", title: "First task", status: "todo" as const },
|
||||
];
|
||||
const lateTasks = [
|
||||
{ id: "1", title: "First task", status: "done" as const },
|
||||
{ id: "2", title: "New task", status: "in_progress" as const },
|
||||
];
|
||||
|
||||
const event1 = createV0TaskTrackingObservation(1, "plan", earlyTasks);
|
||||
const event2 = createV0TaskTrackingObservation(2, "plan", lateTasks);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [event1, event2],
|
||||
eventIds: new Set([1, 2]),
|
||||
uiEvents: [event1, event2],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual(lateTasks);
|
||||
expect(result.current.hasTaskList).toBe(true);
|
||||
});
|
||||
|
||||
it("updates when new events are added to the store", () => {
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.hasTaskList).toBe(false);
|
||||
|
||||
const tasks = [{ id: "1", title: "New task", status: "todo" as const }];
|
||||
const event = createV0TaskTrackingObservation(1, "plan", tasks);
|
||||
|
||||
act(() => {
|
||||
useEventStore.setState({
|
||||
events: [event],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [event],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.taskList).toEqual(tasks);
|
||||
expect(result.current.hasTaskList).toBe(true);
|
||||
});
|
||||
|
||||
it("returns hasTaskList=false when the latest plan has an empty task list", () => {
|
||||
const event = createV0TaskTrackingObservation(1, "plan", []);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [event],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [event],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual([]);
|
||||
expect(result.current.hasTaskList).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("v1 events", () => {
|
||||
it('returns the task list from a v1 TaskTrackerObservation with command="plan"', () => {
|
||||
const tasks = [
|
||||
{ title: "First task", notes: "", status: "todo" as const },
|
||||
{
|
||||
title: "Second task",
|
||||
notes: "some note",
|
||||
status: "in_progress" as const,
|
||||
},
|
||||
];
|
||||
const event = createV1TaskTrackerObservation("1", "plan", tasks);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [event],
|
||||
eventIds: new Set(["1"]),
|
||||
uiEvents: [event],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual([
|
||||
{ id: "1", title: "First task", notes: undefined, status: "todo" },
|
||||
{
|
||||
id: "2",
|
||||
title: "Second task",
|
||||
notes: "some note",
|
||||
status: "in_progress",
|
||||
},
|
||||
]);
|
||||
expect(result.current.hasTaskList).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores v1 TaskTrackerObservation with command !== "plan"', () => {
|
||||
const tasks = [
|
||||
{ title: "First task", notes: "", status: "todo" as const },
|
||||
];
|
||||
const event = createV1TaskTrackerObservation("1", "view", tasks);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [event],
|
||||
eventIds: new Set(["1"]),
|
||||
uiEvents: [event],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual([]);
|
||||
expect(result.current.hasTaskList).toBe(false);
|
||||
});
|
||||
|
||||
it("returns the latest v1 task list when multiple plan events exist", () => {
|
||||
const earlyTasks = [
|
||||
{ title: "First task", notes: "", status: "todo" as const },
|
||||
];
|
||||
const lateTasks = [
|
||||
{ title: "First task", notes: "", status: "done" as const },
|
||||
{ title: "New task", notes: "wip", status: "in_progress" as const },
|
||||
];
|
||||
|
||||
const event1 = createV1TaskTrackerObservation("1", "plan", earlyTasks);
|
||||
const event2 = createV1TaskTrackerObservation("2", "plan", lateTasks);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [event1, event2],
|
||||
eventIds: new Set(["1", "2"]),
|
||||
uiEvents: [event1, event2],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual([
|
||||
{ id: "1", title: "First task", notes: undefined, status: "done" },
|
||||
{ id: "2", title: "New task", notes: "wip", status: "in_progress" },
|
||||
]);
|
||||
expect(result.current.hasTaskList).toBe(true);
|
||||
});
|
||||
|
||||
it("returns hasTaskList=false when the latest v1 plan has an empty task list", () => {
|
||||
const event = createV1TaskTrackerObservation("1", "plan", []);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [event],
|
||||
eventIds: new Set(["1"]),
|
||||
uiEvents: [event],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTaskList());
|
||||
|
||||
expect(result.current.taskList).toEqual([]);
|
||||
expect(result.current.hasTaskList).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
167
frontend/__tests__/routes/task-list-tab.test.tsx
Normal file
167
frontend/__tests__/routes/task-list-tab.test.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import TaskListTab from "#/routes/task-list-tab";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import type { TaskTrackingObservation } from "#/types/core/observations";
|
||||
|
||||
// Mock i18n
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
COMMON$NO_TASKS: "No tasks yet",
|
||||
TASK_TRACKING_OBSERVATION$TASK_NOTES: "Notes",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
function createTaskTrackingObservation(
|
||||
id: number,
|
||||
tasks: TaskTrackingObservation["extras"]["task_list"],
|
||||
): TaskTrackingObservation {
|
||||
return {
|
||||
id,
|
||||
source: "agent",
|
||||
observation: "task_tracking",
|
||||
message: "Task tracking update",
|
||||
timestamp: `2025-07-01T00:00:0${id}Z`,
|
||||
cause: 0,
|
||||
content: "",
|
||||
extras: {
|
||||
command: "plan",
|
||||
task_list: tasks,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setTasks(tasks: TaskTrackingObservation["extras"]["task_list"]) {
|
||||
const event = createTaskTrackingObservation(1, tasks);
|
||||
useEventStore.setState({
|
||||
events: [event],
|
||||
eventIds: new Set([1]),
|
||||
uiEvents: [event],
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useEventStore.setState({
|
||||
events: [],
|
||||
eventIds: new Set(),
|
||||
uiEvents: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskListTab", () => {
|
||||
it("renders empty state with icon and message when there are no tasks", () => {
|
||||
const { container } = render(<TaskListTab />);
|
||||
|
||||
expect(screen.getByText("No tasks yet")).toBeInTheDocument();
|
||||
// Empty state should show the check-circle icon (rendered as SVG)
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders empty state message using Text component (span)", () => {
|
||||
render(<TaskListTab />);
|
||||
|
||||
const message = screen.getByText("No tasks yet");
|
||||
expect(message.tagName).toBe("SPAN");
|
||||
});
|
||||
|
||||
it("renders task items when tasks exist", () => {
|
||||
setTasks([
|
||||
{ id: "1", title: "Implement feature", status: "todo" },
|
||||
{ id: "2", title: "Write tests", status: "in_progress" },
|
||||
{ id: "3", title: "Deploy", status: "done" },
|
||||
]);
|
||||
|
||||
const { container } = render(<TaskListTab />);
|
||||
|
||||
expect(screen.getByText("Implement feature")).toBeInTheDocument();
|
||||
expect(screen.getByText("Write tests")).toBeInTheDocument();
|
||||
expect(screen.getByText("Deploy")).toBeInTheDocument();
|
||||
|
||||
const taskItems = container.querySelectorAll('[data-name="item"]');
|
||||
expect(taskItems).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("does not display task IDs", () => {
|
||||
setTasks([
|
||||
{ id: "task-1", title: "First task", status: "todo" },
|
||||
]);
|
||||
|
||||
render(<TaskListTab />);
|
||||
|
||||
expect(screen.queryByText(/task-1/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights in_progress tasks with a background", () => {
|
||||
setTasks([
|
||||
{ id: "1", title: "Todo task", status: "todo" },
|
||||
{ id: "2", title: "Active task", status: "in_progress" },
|
||||
{ id: "3", title: "Done task", status: "done" },
|
||||
]);
|
||||
|
||||
render(<TaskListTab />);
|
||||
|
||||
// Find each task item via its text, then check the wrapper div
|
||||
const activeItem = screen.getByText("Active task").closest("[data-name]");
|
||||
const activeWrapper = activeItem?.parentElement;
|
||||
expect(activeWrapper?.className).toContain("bg-[#2D3039]");
|
||||
|
||||
const todoItem = screen.getByText("Todo task").closest("[data-name]");
|
||||
expect(todoItem?.parentElement?.className).not.toContain("bg-[#2D3039]");
|
||||
|
||||
const doneItem = screen.getByText("Done task").closest("[data-name]");
|
||||
expect(doneItem?.parentElement?.className).not.toContain("bg-[#2D3039]");
|
||||
});
|
||||
|
||||
it("displays task notes when present and omits when absent", () => {
|
||||
setTasks([
|
||||
{
|
||||
id: "1",
|
||||
title: "Task with notes",
|
||||
status: "todo",
|
||||
notes: "Important note",
|
||||
},
|
||||
{ id: "2", title: "Task without notes", status: "todo" },
|
||||
]);
|
||||
|
||||
render(<TaskListTab />);
|
||||
|
||||
expect(screen.getByText("Notes: Important note")).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/^Notes:/)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses the latest plan event when multiple exist", () => {
|
||||
const event1 = createTaskTrackingObservation(1, [
|
||||
{ id: "1", title: "Old task", status: "todo" },
|
||||
]);
|
||||
const event2 = createTaskTrackingObservation(2, [
|
||||
{ id: "1", title: "Updated task", status: "done" },
|
||||
{ id: "2", title: "New task", status: "in_progress" },
|
||||
]);
|
||||
|
||||
useEventStore.setState({
|
||||
events: [event1, event2],
|
||||
eventIds: new Set([1, 2]),
|
||||
uiEvents: [event1, event2],
|
||||
});
|
||||
|
||||
render(<TaskListTab />);
|
||||
|
||||
expect(screen.queryByText("Old task")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Updated task")).toBeInTheDocument();
|
||||
expect(screen.getByText("New task")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders as a scrollable main element when tasks exist", () => {
|
||||
setTasks([{ id: "1", title: "A task", status: "todo" }]);
|
||||
|
||||
render(<TaskListTab />);
|
||||
|
||||
const main = screen.getByRole("main");
|
||||
expect(main).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -35,23 +35,17 @@ export function TaskItem({ task }: TaskItemProps) {
|
||||
const isDoneStatus = task.status === "done";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-[14px] items-center px-4 py-2 w-full"
|
||||
data-name="item"
|
||||
>
|
||||
<div className="flex gap-2 items-center w-full" data-name="item">
|
||||
<div className="shrink-0">{icon}</div>
|
||||
<div className="flex flex-col items-start justify-center leading-[20px] text-nowrap whitespace-pre font-normal">
|
||||
<div className="flex flex-col items-start justify-center leading-[16px] text-nowrap whitespace-pre font-normal">
|
||||
<Typography.Text
|
||||
className={cn(
|
||||
"text-[12px] text-white",
|
||||
isDoneStatus && "text-[#A3A3A3]",
|
||||
"text-[12px]",
|
||||
isDoneStatus ? "text-[#A3A3A3]" : "text-white",
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="text-[10px] text-[#A3A3A3] font-normal">
|
||||
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_ID)}: {task.id}
|
||||
</Typography.Text>
|
||||
{task.notes && (
|
||||
<Typography.Text className="text-[10px] text-[#A3A3A3]">
|
||||
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes}
|
||||
|
||||
@@ -16,8 +16,13 @@ const BrowserTab = lazy(() => import("#/routes/browser-tab"));
|
||||
const ServedTab = lazy(() => import("#/routes/served-tab"));
|
||||
const VSCodeTab = lazy(() => import("#/routes/vscode-tab"));
|
||||
const PlannerTab = lazy(() => import("#/routes/planner-tab"));
|
||||
const TaskListTab = lazy(() => import("#/routes/task-list-tab"));
|
||||
|
||||
const TAB_CONFIG = {
|
||||
tasklist: {
|
||||
component: TaskListTab,
|
||||
titleKey: I18nKey.COMMON$TASK_LIST,
|
||||
},
|
||||
editor: {
|
||||
component: EditorTab,
|
||||
titleKey: I18nKey.COMMON$CHANGES,
|
||||
|
||||
@@ -27,7 +27,7 @@ export function ConversationTabNav({
|
||||
data-testid={`conversation-tab-${tabValue}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md cursor-pointer",
|
||||
"pl-1.5 pr-2 py-1",
|
||||
"pl-1.5 pr-2 py-1 lg:py-1.5",
|
||||
"text-[#9299AA] bg-[#0D0F11]",
|
||||
isActive && "bg-[#25272D] text-white",
|
||||
isActive
|
||||
|
||||
@@ -13,6 +13,8 @@ import VSCodeIcon from "#/icons/vscode.svg?react";
|
||||
import PillIcon from "#/icons/pill.svg?react";
|
||||
import PillFillIcon from "#/icons/pill-fill.svg?react";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
import DoubleCheckIcon from "#/icons/double-check.svg?react";
|
||||
import { useTaskList } from "#/hooks/use-task-list";
|
||||
|
||||
interface ConversationTabsContextMenuProps {
|
||||
isOpen: boolean;
|
||||
@@ -29,6 +31,8 @@ export function ConversationTabsContextMenu({
|
||||
const { state, setUnpinnedTabs } =
|
||||
useConversationLocalStorageState(conversationId);
|
||||
|
||||
const { hasTaskList } = useTaskList();
|
||||
|
||||
const tabConfig = [
|
||||
{
|
||||
tab: "planner",
|
||||
@@ -42,6 +46,14 @@ export function ConversationTabsContextMenu({
|
||||
{ tab: "browser", icon: GlobeIcon, i18nKey: I18nKey.COMMON$BROWSER },
|
||||
];
|
||||
|
||||
if (hasTaskList) {
|
||||
tabConfig.unshift({
|
||||
tab: "tasklist",
|
||||
icon: DoubleCheckIcon,
|
||||
i18nKey: I18nKey.COMMON$TASK_LIST,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleTabClick = (tab: string) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import GitChanges from "#/icons/git_changes.svg?react";
|
||||
import VSCodeIcon from "#/icons/vscode.svg?react";
|
||||
import ThreeDotsVerticalIcon from "#/icons/three-dots-vertical.svg?react";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
import DoubleCheckIcon from "#/icons/double-check.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useConversationLocalStorageState } from "#/utils/conversation-local-storage";
|
||||
import { ConversationTabNav } from "./conversation-tab-nav";
|
||||
@@ -17,6 +18,7 @@ import { useConversationStore } from "#/stores/conversation-store";
|
||||
import { ConversationTabsContextMenu } from "./conversation-tabs-context-menu";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useSelectConversationTab } from "#/hooks/use-select-conversation-tab";
|
||||
import { useTaskList } from "#/hooks/use-task-list";
|
||||
|
||||
export function ConversationTabs() {
|
||||
const { conversationId } = useConversationId();
|
||||
@@ -27,6 +29,8 @@ export function ConversationTabs() {
|
||||
const { state: persistedState } =
|
||||
useConversationLocalStorageState(conversationId);
|
||||
|
||||
const { hasTaskList } = useTaskList();
|
||||
|
||||
const {
|
||||
selectTab,
|
||||
isTabActive,
|
||||
@@ -120,6 +124,18 @@ export function ConversationTabs() {
|
||||
},
|
||||
];
|
||||
|
||||
if (hasTaskList) {
|
||||
tabs.unshift({
|
||||
tabValue: "tasklist",
|
||||
isActive: isTabActive("tasklist"),
|
||||
icon: DoubleCheckIcon,
|
||||
onClick: () => selectTab("tasklist"),
|
||||
tooltipContent: t(I18nKey.COMMON$TASK_LIST),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$TASK_LIST),
|
||||
label: t(I18nKey.COMMON$TASK_LIST),
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out unpinned tabs
|
||||
const visibleTabs = tabs.filter(
|
||||
(tab) => !persistedState.unpinnedTabs.includes(tab.tabValue),
|
||||
|
||||
64
frontend/src/hooks/use-task-list.ts
Normal file
64
frontend/src/hooks/use-task-list.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useMemo } from "react";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import type { OHEvent } from "#/stores/use-event-store";
|
||||
import { isTaskTrackingObservation } from "#/types/core/guards";
|
||||
import type { OpenHandsParsedEvent } from "#/types/core";
|
||||
import { isObservationEvent } from "#/types/v1/type-guards";
|
||||
import type { OpenHandsEvent } from "#/types/v1/core";
|
||||
import type { TaskTrackerObservation } from "#/types/v1/core/base/observation";
|
||||
import type { ObservationEvent } from "#/types/v1/core/events/observation-event";
|
||||
|
||||
export interface TaskListItem {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "todo" | "in_progress" | "done";
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
function getTaskListFromEvent(event: OHEvent): TaskListItem[] | null {
|
||||
// v0 event format: observation is a string "task_tracking"
|
||||
const v0 = event as OpenHandsParsedEvent;
|
||||
if (isTaskTrackingObservation(v0) && v0.extras.command === "plan") {
|
||||
return v0.extras.task_list.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
notes: t.notes,
|
||||
}));
|
||||
}
|
||||
|
||||
// v1 event format: observation is an object with kind "TaskTrackerObservation"
|
||||
const v1 = event as OpenHandsEvent;
|
||||
if (
|
||||
isObservationEvent(v1) &&
|
||||
v1.observation.kind === "TaskTrackerObservation"
|
||||
) {
|
||||
const obs = (v1 as ObservationEvent<TaskTrackerObservation>).observation;
|
||||
if (obs.command === "plan") {
|
||||
return obs.task_list.map((t, i) => ({
|
||||
id: String(i + 1),
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
notes: t.notes || undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useTaskList() {
|
||||
const events = useEventStore((state) => state.events);
|
||||
|
||||
return useMemo(() => {
|
||||
// Iterate in reverse to find the latest TaskTrackingObservation with command="plan"
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const taskList = getTaskListFromEvent(events[i]);
|
||||
if (taskList) {
|
||||
return { taskList, hasTaskList: taskList.length > 0 };
|
||||
}
|
||||
}
|
||||
|
||||
return { taskList: [] as TaskListItem[], hasTaskList: false };
|
||||
}, [events]);
|
||||
}
|
||||
@@ -993,6 +993,8 @@ export enum I18nKey {
|
||||
COMMON$MORE_OPTIONS = "COMMON$MORE_OPTIONS",
|
||||
COMMON$CREATE_A_PLAN = "COMMON$CREATE_A_PLAN",
|
||||
COMMON$TASKS = "COMMON$TASKS",
|
||||
COMMON$TASK_LIST = "COMMON$TASK_LIST",
|
||||
COMMON$NO_TASKS = "COMMON$NO_TASKS",
|
||||
COMMON$PLAN_MD = "COMMON$PLAN_MD",
|
||||
COMMON$READ_MORE = "COMMON$READ_MORE",
|
||||
COMMON$BUILD = "COMMON$BUILD",
|
||||
|
||||
@@ -15891,6 +15891,38 @@
|
||||
"de": "Aufgaben",
|
||||
"uk": "Завдання"
|
||||
},
|
||||
"COMMON$TASK_LIST": {
|
||||
"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$NO_TASKS": {
|
||||
"en": "No tasks yet",
|
||||
"ja": "タスクはまだありません",
|
||||
"zh-CN": "暂无任务",
|
||||
"zh-TW": "尚無任務",
|
||||
"ko-KR": "아직 작업이 없습니다",
|
||||
"no": "Ingen oppgaver ennå",
|
||||
"it": "Nessuna attività",
|
||||
"pt": "Nenhuma tarefa ainda",
|
||||
"es": "Sin tareas aún",
|
||||
"ar": "لا توجد مهام بعد",
|
||||
"fr": "Aucune tâche pour le moment",
|
||||
"tr": "Henüz görev yok",
|
||||
"de": "Noch keine Aufgaben",
|
||||
"uk": "Завдань поки немає"
|
||||
},
|
||||
"COMMON$PLAN_MD": {
|
||||
"en": "Plan.md",
|
||||
"ja": "Plan.md",
|
||||
|
||||
4
frontend/src/icons/double-check.svg
Normal file
4
frontend/src/icons/double-check.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 10.5L6.5 14L14 6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M7 10.5L10.5 14L18 6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 348 B |
41
frontend/src/routes/task-list-tab.tsx
Normal file
41
frontend/src/routes/task-list-tab.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import CheckCircleIcon from "#/icons/u-check-circle.svg?react";
|
||||
import { TaskItem } from "#/components/features/chat/task-tracking/task-item";
|
||||
import { useTaskList } from "#/hooks/use-task-list";
|
||||
import { Text } from "#/ui/typography";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
function TaskListTab() {
|
||||
const { t } = useTranslation();
|
||||
const { taskList } = useTaskList();
|
||||
|
||||
if (taskList.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full p-10 gap-4">
|
||||
<CheckCircleIcon width={109} height={109} color="#A1A1A1" />
|
||||
<Text className="text-[#8D95A9] text-[19px] font-normal leading-5">
|
||||
{t(I18nKey.COMMON$NO_TASKS)}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="h-full overflow-y-auto flex flex-col custom-scrollbar-always">
|
||||
{taskList.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className={cn(
|
||||
"px-4 py-2",
|
||||
task.status === "in_progress" && "bg-[#2D3039]",
|
||||
)}
|
||||
>
|
||||
<TaskItem task={task} />
|
||||
</div>
|
||||
))}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default TaskListTab;
|
||||
@@ -11,7 +11,8 @@ export type ConversationTab =
|
||||
| "served"
|
||||
| "vscode"
|
||||
| "terminal"
|
||||
| "planner";
|
||||
| "planner"
|
||||
| "tasklist";
|
||||
|
||||
export type ConversationMode = "code" | "plan";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user