From 145f1266e62df875fa35c63824e525f6f865bd7e Mon Sep 17 00:00:00 2001 From: Dream <42954461+eureka928@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:31:38 -0400 Subject: [PATCH] feat(frontend): create a separate UI tab for monitoring tasks (#13065) Co-authored-by: hieptl --- ...task-tracking-observation-content.test.tsx | 8 +- .../conversation-tabs-context-menu.test.tsx | 83 ++++++ .../conversation/conversation-tabs.test.tsx | 84 +++--- .../__tests__/hooks/use-task-list.test.ts | 279 ++++++++++++++++++ .../__tests__/routes/task-list-tab.test.tsx | 167 +++++++++++ .../features/chat/task-tracking/task-item.tsx | 14 +- .../conversation-tab-content.tsx | 5 + .../conversation-tab-nav.tsx | 2 +- .../conversation-tabs-context-menu.tsx | 12 + .../conversation-tabs/conversation-tabs.tsx | 16 + frontend/src/hooks/use-task-list.ts | 64 ++++ frontend/src/i18n/declaration.ts | 2 + frontend/src/i18n/translation.json | 32 ++ frontend/src/icons/double-check.svg | 4 + frontend/src/routes/task-list-tab.tsx | 41 +++ frontend/src/stores/conversation-store.ts | 3 +- 16 files changed, 758 insertions(+), 58 deletions(-) create mode 100644 frontend/__tests__/components/features/conversation/conversation-tabs-context-menu.test.tsx create mode 100644 frontend/__tests__/hooks/use-task-list.test.ts create mode 100644 frontend/__tests__/routes/task-list-tab.test.tsx create mode 100644 frontend/src/hooks/use-task-list.ts create mode 100644 frontend/src/icons/double-check.svg create mode 100644 frontend/src/routes/task-list-tab.tsx diff --git a/frontend/__tests__/components/features/chat/task-tracking-observation-content.test.tsx b/frontend/__tests__/components/features/chat/task-tracking-observation-content.test.tsx index a905b77c92..399179b1a6 100644 --- a/frontend/__tests__/components/features/chat/task-tracking-observation-content.test.tsx +++ b/frontend/__tests__/components/features/chat/task-tracking-observation-content.test.tsx @@ -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(); - 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( diff --git a/frontend/__tests__/components/features/conversation/conversation-tabs-context-menu.test.tsx b/frontend/__tests__/components/features/conversation/conversation-tabs-context-menu.test.tsx new file mode 100644 index 0000000000..dea587f26f --- /dev/null +++ b/frontend/__tests__/components/features/conversation/conversation-tabs-context-menu.test.tsx @@ -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( + , + ); + + expect(container.innerHTML).toBe(""); + }); + + it("should render all default tabs when open", () => { + render(); + + 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(); + + 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(); + + expect(screen.getByText("COMMON$TASK_LIST")).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/__tests__/components/features/conversation/conversation-tabs.test.tsx b/frontend/__tests__/components/features/conversation/conversation-tabs.test.tsx index 5e8a45ae2c..03df18d2e0 100644 --- a/frontend/__tests__/components/features/conversation/conversation-tabs.test.tsx +++ b/frontend/__tests__/components/features/conversation/conversation-tabs.test.tsx @@ -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 }) => ( @@ -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(); - - 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( - <> - - - , - { 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(, { + 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(, { + 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); + }); + }); }); diff --git a/frontend/__tests__/hooks/use-task-list.test.ts b/frontend/__tests__/hooks/use-task-list.test.ts new file mode 100644 index 0000000000..319029a938 --- /dev/null +++ b/frontend/__tests__/hooks/use-task-list.test.ts @@ -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); + }); + }); +}); diff --git a/frontend/__tests__/routes/task-list-tab.test.tsx b/frontend/__tests__/routes/task-list-tab.test.tsx new file mode 100644 index 0000000000..605781e825 --- /dev/null +++ b/frontend/__tests__/routes/task-list-tab.test.tsx @@ -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 = { + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + // 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(); + + 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(); + + 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(); + + const main = screen.getByRole("main"); + expect(main).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/features/chat/task-tracking/task-item.tsx b/frontend/src/components/features/chat/task-tracking/task-item.tsx index bb997ca139..796187a1a5 100644 --- a/frontend/src/components/features/chat/task-tracking/task-item.tsx +++ b/frontend/src/components/features/chat/task-tracking/task-item.tsx @@ -35,23 +35,17 @@ export function TaskItem({ task }: TaskItemProps) { const isDoneStatus = task.status === "done"; return ( -
+
{icon}
-
+
{task.title} - - {t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_ID)}: {task.id} - {task.notes && ( {t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes} diff --git a/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content.tsx b/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content.tsx index 29332d2afc..fa37243a43 100644 --- a/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content.tsx +++ b/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-content/conversation-tab-content.tsx @@ -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, diff --git a/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-nav.tsx b/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-nav.tsx index cc905fc7e5..2457928852 100644 --- a/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-nav.tsx +++ b/frontend/src/components/features/conversation/conversation-tabs/conversation-tab-nav.tsx @@ -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 diff --git a/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs-context-menu.tsx b/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs-context-menu.tsx index 9b72b2d827..e90817f56c 100644 --- a/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs-context-menu.tsx +++ b/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs-context-menu.tsx @@ -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) => { diff --git a/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx b/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx index 0b82910ea1..4060cd2bb6 100644 --- a/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx +++ b/frontend/src/components/features/conversation/conversation-tabs/conversation-tabs.tsx @@ -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), diff --git a/frontend/src/hooks/use-task-list.ts b/frontend/src/hooks/use-task-list.ts new file mode 100644 index 0000000000..2790a1b64e --- /dev/null +++ b/frontend/src/hooks/use-task-list.ts @@ -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).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]); +} diff --git a/frontend/src/i18n/declaration.ts b/frontend/src/i18n/declaration.ts index 44ff4dcc38..b968be1ec9 100644 --- a/frontend/src/i18n/declaration.ts +++ b/frontend/src/i18n/declaration.ts @@ -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", diff --git a/frontend/src/i18n/translation.json b/frontend/src/i18n/translation.json index d84602daab..1817da31f7 100644 --- a/frontend/src/i18n/translation.json +++ b/frontend/src/i18n/translation.json @@ -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", diff --git a/frontend/src/icons/double-check.svg b/frontend/src/icons/double-check.svg new file mode 100644 index 0000000000..6205a25f89 --- /dev/null +++ b/frontend/src/icons/double-check.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/routes/task-list-tab.tsx b/frontend/src/routes/task-list-tab.tsx new file mode 100644 index 0000000000..43280e3abb --- /dev/null +++ b/frontend/src/routes/task-list-tab.tsx @@ -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 ( +
+ + + {t(I18nKey.COMMON$NO_TASKS)} + +
+ ); + } + + return ( +
+ {taskList.map((task) => ( +
+ +
+ ))} +
+ ); +} + +export default TaskListTab; diff --git a/frontend/src/stores/conversation-store.ts b/frontend/src/stores/conversation-store.ts index 94103d23c4..3d864fe14e 100644 --- a/frontend/src/stores/conversation-store.ts +++ b/frontend/src/stores/conversation-store.ts @@ -11,7 +11,8 @@ export type ConversationTab = | "served" | "vscode" | "terminal" - | "planner"; + | "planner" + | "tasklist"; export type ConversationMode = "code" | "plan";