feat(frontend): create a separate UI tab for monitoring tasks (#13065)

Co-authored-by: hieptl <hieptl.developer@gmail.com>
This commit is contained in:
Dream
2026-03-10 11:31:38 -04:00
committed by GitHub
parent e12dd924ce
commit 145f1266e6
16 changed files with 758 additions and 58 deletions

View File

@@ -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(

View File

@@ -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();
});
});
});

View File

@@ -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);
});
});
});

View 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);
});
});
});

View 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();
});
});

View File

@@ -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}

View File

@@ -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,

View File

@@ -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

View File

@@ -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) => {

View File

@@ -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),

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

View File

@@ -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",

View File

@@ -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",

View 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

View 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;

View File

@@ -11,7 +11,8 @@ export type ConversationTab =
| "served"
| "vscode"
| "terminal"
| "planner";
| "planner"
| "tasklist";
export type ConversationMode = "code" | "plan";