From 1e4e51a80cfa837a0983092e05cd3af4e37e012d Mon Sep 17 00:00:00 2001 From: Henry Li Date: Thu, 22 Jan 2026 00:26:11 +0800 Subject: [PATCH] feat: add Todos --- .../workspace/chats/[thread_id]/layout.tsx | 2 +- .../app/workspace/chats/[thread_id]/page.tsx | 37 +++++++--- .../components/ai-elements/prompt-input.tsx | 2 +- .../workspace/artifacts/context.tsx | 15 ++-- .../src/components/workspace/input-box.tsx | 61 +++++++++++++++-- .../workspace/messages/message-group.tsx | 52 +++++++------- .../workspace/messages/message-list.tsx | 4 +- .../src/components/workspace/todo-list.tsx | 68 +++++++++++++++++++ frontend/src/core/i18n/locales/en-US.ts | 6 ++ frontend/src/core/i18n/locales/types.ts | 6 ++ frontend/src/core/i18n/locales/zh-CN.ts | 6 ++ frontend/src/core/messages/utils.ts | 23 ------- frontend/src/core/settings/local.ts | 13 +++- frontend/src/core/threads/types.ts | 4 ++ frontend/src/core/todos/index.ts | 1 + frontend/src/core/todos/types.ts | 4 ++ 16 files changed, 232 insertions(+), 72 deletions(-) create mode 100644 frontend/src/components/workspace/todo-list.tsx create mode 100644 frontend/src/core/todos/index.ts create mode 100644 frontend/src/core/todos/types.ts diff --git a/frontend/src/app/workspace/chats/[thread_id]/layout.tsx b/frontend/src/app/workspace/chats/[thread_id]/layout.tsx index 71c2b5e..48effe4 100644 --- a/frontend/src/app/workspace/chats/[thread_id]/layout.tsx +++ b/frontend/src/app/workspace/chats/[thread_id]/layout.tsx @@ -28,7 +28,7 @@ export default function ChatLayout({ color={ resolvedTheme === "dark" ? "#60A5FA" : "oklch(0 0.0098 87.47)" } - maxOpacity={resolvedTheme === "dark" ? 0.04 : 0.03} + maxOpacity={resolvedTheme === "dark" ? 0.04 : 0.025} flickerChance={0.1} />
@@ -149,19 +153,34 @@ export default function ChatPage() { : "max-w-(--container-width-md)", )} > -
- -
+ {isNewThread && ( +
+ +
+ )} + + setTodoListCollapsed(!todoListCollapsed) + } + /> +
+ ) : null + } onContextChange={(context) => setSettings("context", context) } diff --git a/frontend/src/components/ai-elements/prompt-input.tsx b/frontend/src/components/ai-elements/prompt-input.tsx index d18b3a6..254ce11 100644 --- a/frontend/src/components/ai-elements/prompt-input.tsx +++ b/frontend/src/components/ai-elements/prompt-input.tsx @@ -789,7 +789,7 @@ export const PromptInput = ({ ref={formRef} {...props} > - {children} + {children} ); diff --git a/frontend/src/components/workspace/artifacts/context.tsx b/frontend/src/components/workspace/artifacts/context.tsx index 1058ae8..a7621f4 100644 --- a/frontend/src/components/workspace/artifacts/context.tsx +++ b/frontend/src/components/workspace/artifacts/context.tsx @@ -7,13 +7,13 @@ export interface ArtifactsContextType { setArtifacts: (artifacts: string[]) => void; selectedArtifact: string | null; + autoSelect: boolean; + select: (artifact: string, autoSelect?: boolean) => void; + deselect: () => void; open: boolean; autoOpen: boolean; setOpen: (open: boolean) => void; - deselect: () => void; - - select: (artifact: string) => void; } const ArtifactsContext = createContext( @@ -27,17 +27,22 @@ interface ArtifactsProviderProps { export function ArtifactsProvider({ children }: ArtifactsProviderProps) { const [artifacts, setArtifacts] = useState([]); const [selectedArtifact, setSelectedArtifact] = useState(null); + const [autoSelect, setAutoSelect] = useState(true); const [open, setOpen] = useState(false); const [autoOpen, setAutoOpen] = useState(true); const { setOpen: setSidebarOpen } = useSidebar(); - const select = (artifact: string) => { + const select = (artifact: string, autoSelect = false) => { setSelectedArtifact(artifact); setSidebarOpen(false); + if (!autoSelect) { + setAutoSelect(false); + } }; const deselect = () => { setSelectedArtifact(null); + setAutoSelect(true); }; const value: ArtifactsContextType = { @@ -46,9 +51,11 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) { open, autoOpen, + autoSelect, setOpen: (isOpen: boolean) => { if (!isOpen && autoOpen) { setAutoOpen(false); + setAutoSelect(false); } setOpen(isOpen); }, diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index ca9269a..280698e 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -1,7 +1,7 @@ "use client"; import type { ChatStatus } from "ai"; -import { CheckIcon, LightbulbIcon, LightbulbOffIcon } from "lucide-react"; +import { CheckIcon, LightbulbIcon, ListTodoIcon } from "lucide-react"; import { useCallback, useMemo, useState, type ComponentProps } from "react"; import { @@ -35,6 +35,7 @@ export function InputBox({ autoFocus, status = "ready", context, + extraHeader, onContextChange, onSubmit, onStop, @@ -43,6 +44,7 @@ export function InputBox({ assistantId?: string | null; status?: ChatStatus; context: Omit; + extraHeader?: React.ReactNode; onContextChange?: (context: Omit) => void; onSubmit?: (message: PromptInputMessage) => void; onStop?: () => void; @@ -72,6 +74,12 @@ export function InputBox({ thinking_enabled: !context.thinking_enabled, }); }, [onContextChange, context]); + const handlePlanModeToggle = useCallback(() => { + onContextChange?.({ + ...context, + is_plan_mode: !context.is_plan_mode, + }); + }, [onContextChange, context]); const handleSubmit = useCallback( async (message: PromptInputMessage) => { if (status === "streaming") { @@ -89,7 +97,6 @@ export function InputBox({ + {extraHeader && ( +
+
{extraHeader}
+
+ )} -
+
) : ( - + )} )} + +
{t.inputBox.planMode}
+
+ {t.inputBox.clickToDisablePlanMode} +
+
+ ) : ( +
+
{t.inputBox.planMode}
+
+ {t.inputBox.clickToEnablePlanMode} +
+
+ ) + } + > + {selectedModel?.supports_thinking && ( + + <> + {context.is_plan_mode ? ( + + ) : ( + + )} + + {t.inputBox.planMode} + + + + )} +
) : ( - + ), )} {lastToolCallStep && ( - + )} @@ -173,15 +178,20 @@ function ToolCall({ name, args, result, + isLast = false, + isLoading = false, }: { id?: string; messageId?: string; name: string; args: Record; result?: string | Record; + isLast?: boolean; + isLoading?: boolean; }) { const { t } = useI18n(); - const { setOpen, autoOpen, selectedArtifact, select } = useArtifacts(); + const { setOpen, autoOpen, autoSelect, selectedArtifact, select } = + useArtifacts(); if (name === "web_search") { let label: React.ReactNode = t.toolCalls.searchForRelatedInfo; if (typeof args.query === "string") { @@ -265,7 +275,7 @@ function ToolCall({ description = t.toolCalls.writeFile; } const path: string | undefined = (args as { path: string })?.path; - if (autoOpen && path) { + if (isLoading && isLast && autoOpen && autoSelect && path) { setTimeout(() => { const url = new URL( `write-file:${path}?message_id=${messageId}&tool_call_id=${id}`, @@ -273,7 +283,7 @@ function ToolCall({ if (selectedArtifact === url) { return; } - select(url); + select(url, true); setOpen(true); }, 100); } @@ -320,27 +330,7 @@ function ToolCall({ )} ); - } else if (name === "present_files") { - return ( - - - {Array.isArray((args as { filepaths: string[] }).filepaths) && - (args as { filepaths: string[] }).filepaths.map( - (filepath: string) => ( - - {filepath} - - ), - )} - - - ); - } - if (name === "ask_clarification") { + } else if (name === "ask_clarification") { return ( ); + } else if (name === "write_todos") { + return ( + + ); } else { const description: string | undefined = (args as { description: string }) ?.description; diff --git a/frontend/src/components/workspace/messages/message-list.tsx b/frontend/src/components/workspace/messages/message-list.tsx index 97c11fe..09788e6 100644 --- a/frontend/src/components/workspace/messages/message-list.tsx +++ b/frontend/src/components/workspace/messages/message-list.tsx @@ -23,10 +23,12 @@ export function MessageList({ className, threadId, thread, + paddingBottom = 160, }: { className?: string; threadId: string; thread: UseStream; + paddingBottom?: number; }) { if (thread.isThreadLoading) { return ; @@ -70,7 +72,7 @@ export function MessageList({ ); })} {thread.isLoading && } -
+
); diff --git a/frontend/src/components/workspace/todo-list.tsx b/frontend/src/components/workspace/todo-list.tsx new file mode 100644 index 0000000..a1e31e7 --- /dev/null +++ b/frontend/src/components/workspace/todo-list.tsx @@ -0,0 +1,68 @@ +import { ChevronUpIcon } from "lucide-react"; + +import type { Todo } from "@/core/todos"; +import { cn } from "@/lib/utils"; + +import { + QueueItem, + QueueItemContent, + QueueItemIndicator, + QueueList, +} from "../ai-elements/queue"; + +export function TodoList({ + className, + todos, + collapsed = false, + onToggle, +}: { + className?: string; + todos: Todo[]; + collapsed?: boolean; + onToggle?: () => void; +}) { + return ( +
+
{ + onToggle?.(); + }} + > +
To-dos
+
+ +
+
+
+ + {todos.map((todo, i) => ( + +
+ + + {todo.content} + +
+
+ ))} +
+
+
+ ); +} diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts index 2f87746..5e0ffe9 100644 --- a/frontend/src/core/i18n/locales/en-US.ts +++ b/frontend/src/core/i18n/locales/en-US.ts @@ -41,6 +41,11 @@ export const enUS: Translations = { thinkingDisabled: "Thinking is disabled", clickToDisableThinking: "Click to disable thinking", clickToEnableThinking: "Click to enable thinking", + planMode: "Plan mode", + planModeEnabled: "Plan mode is enabled", + planModeDisabled: "Plan mode is disabled", + clickToDisablePlanMode: "Click to disable plan mode", + clickToEnablePlanMode: "Click to enable plan mode", searchModels: "Search models...", }, @@ -87,6 +92,7 @@ export const enUS: Translations = { listFolder: "List folder", readFile: "Read file", writeFile: "Write file", + writeTodos: "Update to-do list", }, // Settings diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts index 5586983..6279c27 100644 --- a/frontend/src/core/i18n/locales/types.ts +++ b/frontend/src/core/i18n/locales/types.ts @@ -38,6 +38,11 @@ export interface Translations { thinkingDisabled: string; clickToDisableThinking: string; clickToEnableThinking: string; + planMode: string; + planModeEnabled: string; + planModeDisabled: string; + clickToDisablePlanMode: string; + clickToEnablePlanMode: string; searchModels: string; }; @@ -84,6 +89,7 @@ export interface Translations { listFolder: string; readFile: string; writeFile: string; + writeTodos: string; }; // Settings diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts index cea42c8..12afc98 100644 --- a/frontend/src/core/i18n/locales/zh-CN.ts +++ b/frontend/src/core/i18n/locales/zh-CN.ts @@ -41,6 +41,11 @@ export const zhCN: Translations = { thinkingDisabled: "思考功能已禁用", clickToDisableThinking: "点击禁用思考功能", clickToEnableThinking: "点击启用思考功能", + planMode: "To-do 模式", + planModeEnabled: "To-do 模式已启用", + planModeDisabled: "To-do 模式已禁用", + clickToDisablePlanMode: "点击禁用 To-do 模式", + clickToEnablePlanMode: "点击启用 To-do 模式", searchModels: "搜索模型...", }, @@ -87,6 +92,7 @@ export const zhCN: Translations = { listFolder: "列出文件夹", readFile: "读取文件", writeFile: "写入文件", + writeTodos: "更新 To-do 列表", }, // Settings diff --git a/frontend/src/core/messages/utils.ts b/frontend/src/core/messages/utils.ts index faa137e..75ecbff 100644 --- a/frontend/src/core/messages/utils.ts +++ b/frontend/src/core/messages/utils.ts @@ -85,29 +85,6 @@ export function groupMessages( } } - // if (!isLoading) { - // const lastGroup: MessageGroup | undefined = groups[groups.length - 1]; - // if ( - // lastGroup?.type === "assistant:processing" && - // lastGroup.messages.length > 0 - // ) { - // const lastMessage = lastGroup.messages[lastGroup.messages.length - 1]!; - // const reasoningContent = extractReasoningContentFromMessage(lastMessage); - // const content = extractContentFromMessage(lastMessage); - // if (reasoningContent && !content) { - // lastGroup.messages.pop(); - // if (lastGroup.messages.length === 0) { - // groups.pop(); - // } - // groups.push({ - // id: lastMessage.id, - // type: "assistant", - // messages: [lastMessage], - // }); - // } - // } - // } - const resultsOfGroups: T[] = []; for (const group of groups) { const resultOfGroup = mapper(group); diff --git a/frontend/src/core/settings/local.ts b/frontend/src/core/settings/local.ts index 41312c8..c493d09 100644 --- a/frontend/src/core/settings/local.ts +++ b/frontend/src/core/settings/local.ts @@ -4,6 +4,7 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = { context: { model_name: "deepseek-v3.2", thinking_enabled: true, + is_plan_mode: true, }, layout: { sidebar_collapsed: false, @@ -27,10 +28,18 @@ export function getLocalSettings(): LocalSettings { try { if (json) { const settings = JSON.parse(json); - return { + const mergedSettings = { ...DEFAULT_LOCAL_SETTINGS, - ...settings, + context: { + ...DEFAULT_LOCAL_SETTINGS.context, + ...settings.context, + }, + layout: { + ...DEFAULT_LOCAL_SETTINGS.layout, + ...settings.layout, + }, }; + return mergedSettings; } } catch {} return DEFAULT_LOCAL_SETTINGS; diff --git a/frontend/src/core/threads/types.ts b/frontend/src/core/threads/types.ts index cb6d68f..5d4bc43 100644 --- a/frontend/src/core/threads/types.ts +++ b/frontend/src/core/threads/types.ts @@ -1,10 +1,13 @@ import { type BaseMessage } from "@langchain/core/messages"; import type { Thread } from "@langchain/langgraph-sdk"; +import type { Todo } from "../todos"; + export interface AgentThreadState extends Record { title: string; messages: BaseMessage[]; artifacts: string[]; + todos?: Todo[]; } export interface AgentThread extends Thread {} @@ -13,4 +16,5 @@ export interface AgentThreadContext extends Record { thread_id: string; model_name: string; thinking_enabled: boolean; + is_plan_mode: boolean; } diff --git a/frontend/src/core/todos/index.ts b/frontend/src/core/todos/index.ts new file mode 100644 index 0000000..eea524d --- /dev/null +++ b/frontend/src/core/todos/index.ts @@ -0,0 +1 @@ +export * from "./types"; diff --git a/frontend/src/core/todos/types.ts b/frontend/src/core/todos/types.ts new file mode 100644 index 0000000..a515b2a --- /dev/null +++ b/frontend/src/core/todos/types.ts @@ -0,0 +1,4 @@ +export interface Todo { + content?: string; + status?: "pending" | "in_progress" | "completed"; +}