Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Only write entries that are worth mentioning to users.

## Unreleased

- Web: Add todo list display in prompt toolbar — shows task progress with expandable panel when the `SetTodoList` tool is active

## 1.16.0 (2026-02-27)

- Web: Update ASCII logo banner to a new styled design
Expand Down
2 changes: 2 additions & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ This page documents the changes in each Kimi Code CLI release.

## Unreleased

- Web: Add todo list display in prompt toolbar — shows task progress with expandable panel when the `SetTodoList` tool is active

## 1.16.0 (2026-02-27)

- Web: Update ASCII logo banner to a new styled design
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

## 未发布

- Web:在提示输入工具栏中添加待办列表显示——当 `SetTodoList` 工具激活时,显示任务进度并支持展开面板查看详情

## 1.16.0 (2026-02-27)

- Web:更新 ASCII Logo 横幅为新的样式设计
Expand Down
22 changes: 19 additions & 3 deletions web/src/features/chat/components/prompt-toolbar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ import { cn } from "@/lib/utils";
import type { GitDiffStats } from "@/lib/api/models";
import type { TokenUsage } from "@/hooks/wireTypes";
import { useQueueStore } from "../../queue-store";
import { useToolEventsStore } from "@/features/tool/store";
import { ToolbarActivityIndicator, type ActivityDetail } from "../activity-status-indicator";
import { ToolbarQueuePanel, ToolbarQueueTab } from "./toolbar-queue";
import { ToolbarChangesPanel, ToolbarChangesTab } from "./toolbar-changes";
import { ToolbarTodoPanel, ToolbarTodoTab } from "./toolbar-todo";
import { ToolbarContextIndicator } from "./toolbar-context";

// ─── Types ───────────────────────────────────────────────────

type TabId = "queue" | "changes";
type TabId = "queue" | "changes" | "todo";

type PromptToolbarProps = {
gitDiffStats?: GitDiffStats | null;
Expand All @@ -43,14 +45,16 @@ export const PromptToolbar = memo(function PromptToolbarComponent({
tokenUsage,
}: PromptToolbarProps): ReactElement | null {
const queue = useQueueStore((s) => s.queue);
const todoItems = useToolEventsStore((s) => s.todoItems);
const [activeTab, setActiveTab] = useState<TabId | null>(null);
const prevQueueLenRef = useRef(0);

const stats = gitDiffStats;
const hasChanges = Boolean(stats?.isGitRepo && stats.hasChanges && stats.files && !stats.error);
const hasQueue = queue.length > 0;
const hasTodo = todoItems.length > 0;
const hasContext = usagePercent !== undefined && usedTokens !== undefined && maxTokens !== undefined;
const hasTabs = hasQueue || hasChanges;
const hasTabs = hasQueue || hasChanges || hasTodo;

// Auto-open queue tab when first item is added
useEffect(() => {
Expand All @@ -64,7 +68,8 @@ export const PromptToolbar = memo(function PromptToolbarComponent({
useEffect(() => {
if (activeTab === "queue" && !hasQueue) setActiveTab(null);
if (activeTab === "changes" && !hasChanges) setActiveTab(null);
}, [activeTab, hasQueue, hasChanges]);
if (activeTab === "todo" && !hasTodo) setActiveTab(null);
}, [activeTab, hasQueue, hasChanges, hasTodo]);
Comment on lines 68 to +72
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Todo tab missing auto-open behavior described in PR

The PR description states the Tasks tab "auto-opens an expandable panel with per-item status icons", but the implementation has no auto-open logic for the todo tab. The queue tab has auto-open behavior via prevQueueLenRef tracking (lines 59-65 of index.tsx), but the todo tab lacks equivalent logic.

Root Cause

When the first queue item is added, the queue tab auto-opens:

useEffect(() => {
  if (prevQueueLenRef.current === 0 && queue.length > 0) {
    setActiveTab("queue");
  }
  prevQueueLenRef.current = queue.length;
}, [queue.length]);

There is no corresponding effect for the todo tab. When todoItems transitions from empty to non-empty (i.e., the first SetTodoList tool result arrives), the tab appears in the toolbar but does not automatically expand its panel. The user must manually click the tab to see the todo items.

Impact: The todo panel never auto-opens, contradicting the stated feature behavior. Users won't see todo progress unless they notice and click the small tab button.

(Refers to lines 67-72)

Prompt for agents
In web/src/features/chat/components/prompt-toolbar/index.tsx, add auto-open logic for the todo tab similar to the queue tab. Add a prevTodoLenRef = useRef(0) alongside prevQueueLenRef (around line 50), then add a useEffect that sets activeTab to 'todo' when todoItems transitions from 0 to >0:

const prevTodoLenRef = useRef(0);

useEffect(() => {
  if (prevTodoLenRef.current === 0 && todoItems.length > 0) {
    setActiveTab("todo");
  }
  prevTodoLenRef.current = todoItems.length;
}, [todoItems.length]);

Place this after the existing queue auto-open effect (after line 65).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


const toggleTab = useCallback((tab: TabId) => {
setActiveTab((prev) => (prev === tab ? null : tab));
Expand All @@ -81,6 +86,9 @@ export const PromptToolbar = memo(function PromptToolbarComponent({
{activeTab === "changes" && stats && (
<ToolbarChangesPanel stats={stats} workDir={workDir} />
)}
{activeTab === "todo" && (
<ToolbarTodoPanel items={todoItems} />
)}
</div>
)}

Expand All @@ -106,6 +114,14 @@ export const PromptToolbar = memo(function PromptToolbarComponent({
/>
)}

{hasTodo && (
<ToolbarTodoTab
items={todoItems}
isActive={activeTab === "todo"}
onToggle={() => toggleTab("todo")}
/>
)}

{hasContext && (
<ToolbarContextIndicator
usagePercent={usagePercent!}
Expand Down
94 changes: 94 additions & 0 deletions web/src/features/chat/components/prompt-toolbar/toolbar-todo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { type ReactElement, memo } from "react";
import {
CheckCircle2Icon,
CheckSquare2Icon,
ChevronDownIcon,
CircleDotIcon,
CircleIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { TodoItem } from "@/features/tool/store";

// ─── Panel ───────────────────────────────────────────────────

type ToolbarTodoPanelProps = {
items: TodoItem[];
};

export const ToolbarTodoPanel = memo(function ToolbarTodoPanelComponent({
items,
}: ToolbarTodoPanelProps): ReactElement {
return (
<>
{items.map((item) => (
<div
key={item.title}
className="flex items-center gap-2 px-3 py-1 text-xs"
>
{item.status === "done" && (
<CheckCircle2Icon className="size-3 flex-shrink-0 text-emerald-500" />
)}
{item.status === "in_progress" && (
<CircleDotIcon className="size-3 flex-shrink-0 text-blue-500" />
)}
{item.status === "pending" && (
<CircleIcon className="size-3 flex-shrink-0 text-muted-foreground" />
)}
<span
className={cn(
"truncate",
item.status === "done"
? "line-through text-muted-foreground"
: item.status === "in_progress"
? "text-foreground font-medium"
: "text-muted-foreground",
)}
>
{item.title}
</span>
</div>
))}
</>
);
});

// ─── Tab ─────────────────────────────────────────────────────

type ToolbarTodoTabProps = {
items: TodoItem[];
isActive: boolean;
onToggle: () => void;
};

export const ToolbarTodoTab = memo(function ToolbarTodoTabComponent({
items,
isActive,
onToggle,
}: ToolbarTodoTabProps): ReactElement {
const doneCount = items.filter((i) => i.status === "done").length;
const totalCount = items.length;

return (
<button
type="button"
onClick={onToggle}
className={cn(
"flex items-center gap-1.5 h-7 px-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer border",
isActive
? "bg-secondary text-foreground border-border shadow-sm"
: "bg-transparent text-muted-foreground border-border/60 hover:text-foreground hover:border-border",
)}
>
<CheckSquare2Icon className="size-3" />
<span>
{doneCount}/{totalCount} Tasks
</span>
<ChevronDownIcon
className={cn(
"size-3 transition-transform duration-200",
isActive && "rotate-180",
)}
/>
</button>
);
});
14 changes: 14 additions & 0 deletions web/src/features/tool/store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { create } from "zustand";

export type TodoItem = {
title: string;
status: "pending" | "in_progress" | "done";
};

type ToolEventsState = {
/** Files written during the current session/turn */
newFiles: string[];
Expand All @@ -8,6 +13,11 @@ type ToolEventsState = {
addNewFile: (path: string) => void;
/** Clear all new files (e.g., when opening files panel or starting new turn) */
clearNewFiles: () => void;

/** Current todo list from SetTodoList tool */
todoItems: TodoItem[];
setTodoItems: (items: TodoItem[]) => void;
clearTodoItems: () => void;
};

export const useToolEventsStore = create<ToolEventsState>((set) => ({
Expand All @@ -17,6 +27,10 @@ export const useToolEventsStore = create<ToolEventsState>((set) => ({
newFiles: [...state.newFiles, path],
})),
clearNewFiles: () => set({ newFiles: [] }),

todoItems: [],
setTodoItems: (items) => set({ todoItems: items }),
clearTodoItems: () => set({ todoItems: [] }),
}));

/**
Expand Down
15 changes: 14 additions & 1 deletion web/src/hooks/useSessionStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ import {
} from "./wireTypes";
import { createMessageId, getApiBaseUrl } from "./utils";
import { kimiCliVersion } from "@/lib/version";
import { handleToolResult } from "@/features/tool/store";
import { handleToolResult, useToolEventsStore, type TodoItem } from "@/features/tool/store";
import { v4 as uuidV4 } from "uuid";

// Regex patterns moved to top level for performance
Expand Down Expand Up @@ -1291,6 +1291,18 @@ export function useSessionStream(
isReplay,
);
}

// Extract todo list from display blocks
if (!isReplay && Array.isArray(return_value.display)) {
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The todo extraction is skipped during history replay (!isReplay). That means when a user reconnects to an existing session (or refreshes the page), the toolbar won't reflect the current SetTodoList state until a new todo update happens in the live stream. If the goal is to show the current todo state for the session, consider applying the extraction during replay as well (it will naturally converge to the latest todo block by the end of the replay), while keeping any “auto-open” behavior gated to non-replay if needed.

Suggested change
if (!isReplay && Array.isArray(return_value.display)) {
if (Array.isArray(return_value.display)) {

Copilot uses AI. Check for mistakes.
const todoBlock = return_value.display.find(
(d: { type: string }) => d.type === "todo",
);
if (todoBlock) {
useToolEventsStore.getState().setTodoItems(
(todoBlock as unknown as { type: string; items: TodoItem[] }).items,
);
Comment on lines +1301 to +1303
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ToolResult.return_value.display items are typed (and handled elsewhere) as display blocks with a data payload, but this code assumes the todo list lives directly on the block as .items. If the backend sends { type: "todo", data: { items: [...] } } (or any variant without a top-level items), this will set todoItems to undefined and downstream code like todoItems.length will throw. Extract items from (todoBlock as any).items ?? (todoBlock as any).data?.items, validate with Array.isArray, and only then update the store (optionally normalize status values).

Suggested change
useToolEventsStore.getState().setTodoItems(
(todoBlock as { type: string; items: TodoItem[] }).items,
);
const rawItems =
(todoBlock as any).items ?? (todoBlock as any).data?.items;
if (Array.isArray(rawItems)) {
useToolEventsStore.getState().setTodoItems(
rawItems as TodoItem[],
);
}

Copilot uses AI. Check for mistakes.
}
}
Comment on lines +1296 to +1305
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Todo toolbar state not restored during history replay (reconnect/page refresh)

When the page is refreshed or the WebSocket reconnects, history events are replayed. The !isReplay guard at useSessionStream.ts:1296 causes all replayed ToolResult events (including SetTodoList results) to be skipped for toolbar state extraction. This means the toolbar's "Tasks" tab disappears after a reconnection even though the chat messages correctly show the todo display blocks from the replay.

Root cause and impact

The code follows the same !isReplay pattern used by handleToolResult at web/src/features/tool/store.ts:48, which tracks newFiles — a notification-style badge that intentionally shouldn't re-trigger on replay. However, todoItems represents persistent UI state (the current todo list), not a transient notification. The last SetTodoList call's display block contains the full current todo list, and skipping it during replay loses that state.

Scenario:

  1. User triggers SetTodoList → toolbar shows "2/5 Tasks" tab
  2. User refreshes the page (or WebSocket reconnects)
  3. History replay runs — all events have isReplay = true
  4. The if (!isReplay && ...) check skips todo extraction
  5. Toolbar shows no "Tasks" tab, but the chat message still renders the todo display block

Impact: Inconsistent UI after any reconnection — chat shows the todo list in messages but the toolbar indicator is missing until the next SetTodoList tool call.

Prompt for agents
In web/src/hooks/useSessionStream.ts around line 1296, the todo extraction from display blocks should also run during replay to restore the toolbar state. However, during replay you only want the LAST todo state (not intermediate ones). One approach: remove the `!isReplay` guard so todo items are always extracted. Since each SetTodoList call replaces the full list via `setTodoItems`, the last replayed event will set the correct final state. Change line 1296 from `if (!isReplay && Array.isArray(return_value.display))` to `if (Array.isArray(return_value.display))`. This aligns with the fact that todoItems represents persistent state rather than transient notifications.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

break;
}

Expand Down Expand Up @@ -2558,6 +2570,7 @@ export function useSessionStream(
// Reset state for new session
resetState();
setMessages([]);
useToolEventsStore.getState().clearTodoItems();

// Auto-connect if we have a valid sessionId
if (sessionId) {
Expand Down
Loading