Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ OPENROUTER_API_KEY=
# AI MODEL CONFIGURATION
# =============================================================================

# Agent model for main chat interactions (default: qwen/qwen3-coder)
# Agent model for main chat interactions
# NEXT_PUBLIC_AGENT_MODEL=

# Title generation model - lightweight for cost/latency (default: qwen/qwen3-30b-a3)
# Title generation model - lightweight for cost/latency
# NEXT_PUBLIC_TITLE_MODEL=

# =============================================================================
Expand Down
25 changes: 25 additions & 0 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export async function POST(req: NextRequest) {

const stream = createUIMessageStream({
execute: async ({ writer }) => {
// Track if todoWrite tool has been used (to activate todoManager)
let usedTodoWriteTool = false;

// Create tools with user context, mode, and writer
const { tools, getSandbox } = createTools(
userID,
Expand All @@ -65,6 +68,12 @@ export async function POST(req: NextRequest) {
userLocation,
);

// Get all tool names except todoManager for initial activeTools
const allToolNames = Object.keys(tools) as Array<keyof typeof tools>;
const defaultActiveTools = allToolNames.filter(
(name) => name !== "todoManager",
);

// Generate title in parallel if this is the start of a conversation
const titlePromise =
truncatedMessages.length === 1
Expand Down Expand Up @@ -95,12 +104,28 @@ export async function POST(req: NextRequest) {
system: systemPrompt(model.modelId, executionMode),
messages: convertToModelMessages(truncatedMessages),
tools,
activeTools: defaultActiveTools,
abortSignal: req.signal,
headers: getAIHeaders(),
experimental_transform: smoothStream({ chunking: "word" }),
prepareStep: async () => {
if (usedTodoWriteTool) {
return {
activeTools: [
...defaultActiveTools,
"todoManager" as keyof typeof tools,
],
};
}
},
stopWhen: stepCountIs(25),
onChunk: async (chunk) => {
if (chunk.chunk.type === "tool-call") {
// Track if todoWrite tool has been used (to activate todoManager)
if (chunk.chunk.toolName === "todoWrite") {
usedTodoWriteTool = true;
}

if (posthog) {
posthog.capture({
distinctId: userID,
Expand Down
7 changes: 6 additions & 1 deletion app/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ import {
import { useHotkeys } from "react-hotkeys-hook";
import TextareaAutosize from "react-textarea-autosize";
import { useGlobalState } from "../contexts/GlobalState";
import { TodoPanel } from "./TodoPanel";
import type { ChatStatus } from "@/types";

interface ChatInputProps {
onSubmit: (e: React.FormEvent) => void;
onStop: () => void;
status: "ready" | "submitted" | "streaming" | "error";
status: ChatStatus;
isCentered?: boolean;
}

Expand Down Expand Up @@ -61,6 +63,9 @@ export const ChatInput = ({
return (
<div className={`relative px-4 ${isCentered ? "" : "pb-3 mb-4"}`}>
<div className="mx-auto w-full max-w-full sm:max-w-[768px] sm:min-w-[390px] flex flex-col flex-1">
{/* Todo Panel */}
<TodoPanel status={status} />

<div className="flex flex-col gap-3 rounded-[22px] transition-all relative bg-input-chat py-3 max-h-[300px] shadow-[0px_12px_32px_0px_rgba(0,0,0,0.02)] border border-black/8 dark:border-border">
<div className="overflow-y-auto pl-4 pr-2">
<TextareaAutosize
Expand Down
3 changes: 2 additions & 1 deletion app/components/MessageActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import type { ChatStatus } from "@/types";

interface MessageActionsProps {
messageParts: Array<{ type: string; text?: string }>;
Expand All @@ -13,7 +14,7 @@ interface MessageActionsProps {
canRegenerate: boolean;
onRegenerate: () => void;
isHovered: boolean;
status: "ready" | "submitted" | "streaming" | "error";
status: ChatStatus;
}

export const MessageActions = ({
Expand Down
8 changes: 7 additions & 1 deletion app/components/MessagePartHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import { MemoizedMarkdown } from "./MemoizedMarkdown";
import { FileToolsHandler } from "./tools/FileToolsHandler";
import { TerminalToolHandler } from "./tools/TerminalToolHandler";
import { WebSearchToolHandler } from "./tools/WebSearchToolHandler";
import { TodoToolHandler } from "./tools/TodoToolHandler";
import type { ChatStatus } from "@/types";

interface MessagePartHandlerProps {
message: UIMessage;
part: any;
partIndex: number;
status: "ready" | "submitted" | "streaming" | "error";
status: ChatStatus;
}

export const MessagePartHandler = ({
Expand Down Expand Up @@ -57,6 +59,10 @@ export const MessagePartHandler = ({
<TerminalToolHandler message={message} part={part} status={status} />
);

case "tool-todoWrite":
case "tool-todoManager":
return <TodoToolHandler message={message} part={part} status={status} />;

default:
return null;
}
Expand Down
3 changes: 2 additions & 1 deletion app/components/Messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import { Button } from "@/components/ui/button";
import { MemoizedMarkdown } from "./MemoizedMarkdown";
import { useSidebarAutoOpen } from "../hooks/useSidebarAutoOpen";
import { ChatSDKError } from "@/lib/errors";
import type { ChatStatus } from "@/types";

interface MessagesProps {
messages: UIMessage[];
onRegenerate: () => void;
status: "ready" | "submitted" | "streaming" | "error";
status: ChatStatus;
error: Error | null;
scrollRef: RefObject<HTMLDivElement | null>;
contentRef: RefObject<HTMLDivElement | null>;
Expand Down
100 changes: 100 additions & 0 deletions app/components/TodoPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"use client";

import React, { useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import type { ChatStatus, Todo } from "@/types";
import { useGlobalState } from "@/app/contexts/GlobalState";
import { SharedTodoItem } from "@/components/ui/shared-todo-item";

interface TodoPanelProps {
status: ChatStatus;
}

const getTodoStats = (todos: Todo[]) => {
const completed = todos.filter((t) => t.status === "completed").length;
const inProgress = todos.filter((t) => t.status === "in_progress").length;
const pending = todos.filter((t) => t.status === "pending").length;
const cancelled = todos.filter((t) => t.status === "cancelled").length;
const total = todos.length;
const done = completed + cancelled;

return {
completed,
inProgress,
pending,
cancelled,
total,
done,
};
};

export const TodoPanel = ({ status }: TodoPanelProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const { todos } = useGlobalState();
const stats = getTodoStats(todos);

// Don't show panel if no todos exist
if (todos.length === 0) {
return null;
}

// Show panel only when there are active todos (hide when all are finished)
const hasActiveTodos = stats.inProgress > 0 || stats.pending > 0;

if (!hasActiveTodos) {
return null;
}

const handleToggleExpand = () => {
setIsExpanded(!isExpanded);
};

const getHeaderText = () => {
if (stats.done === 0) {
return `${stats.total} To-dos`;
}
return `${stats.done} of ${stats.total} To-dos`;
};

return (
<div className="mx-4 rounded-[22px_22px_0px_0px] shadow-[0px_12px_32px_0px_rgba(0,0,0,0.02)] border border-black/8 dark:border-border border-b-0 bg-input-chat">
{/* Header */}
<div
className={`flex items-center px-4 transition-all duration-300 py-2`}
>
<button
onClick={handleToggleExpand}
className="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer focus:outline-none rounded-md p-1 -m-1 flex-1"
aria-label={isExpanded ? "Collapse todos" : "Expand todos"}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleToggleExpand();
}
}}
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
<div className="flex items-center gap-2">
<h3 className="text-muted-foreground text-sm font-medium">
{getHeaderText()}
</h3>
</div>
</button>
</div>

{/* Todo List - Collapsible */}
{isExpanded && (
<div className="border-t border-border px-4 py-3 space-y-2">
{todos.map((todo) => (
<SharedTodoItem key={todo.id} todo={todo} />
))}
</div>
)}
</div>
);
};
3 changes: 2 additions & 1 deletion app/components/tools/FileToolsHandler.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import ToolBlock from "@/components/ui/tool-block";
import { FilePlus, FileText, FilePen, FileMinus } from "lucide-react";
import { useGlobalState } from "../../contexts/GlobalState";
import type { ChatStatus } from "@/types";

interface FileToolsHandlerProps {
part: any;
status: "ready" | "submitted" | "streaming" | "error";
status: ChatStatus;
}

export const FileToolsHandler = ({ part, status }: FileToolsHandlerProps) => {
Expand Down
4 changes: 2 additions & 2 deletions app/components/tools/TerminalToolHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@ import { CommandResult } from "@e2b/code-interpreter";
import ToolBlock from "@/components/ui/tool-block";
import { Terminal } from "lucide-react";
import { useGlobalState } from "../../contexts/GlobalState";
import type { SidebarTerminal } from "@/types/chat";
import type { ChatStatus, SidebarTerminal } from "@/types/chat";

interface TerminalToolHandlerProps {
message: UIMessage;
part: any;
status: "ready" | "submitted" | "streaming" | "error";
status: ChatStatus;
}

export const TerminalToolHandler = ({
Expand Down
89 changes: 89 additions & 0 deletions app/components/tools/TodoToolHandler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React, { useEffect } from "react";
import { UIMessage } from "@ai-sdk/react";
import ToolBlock from "@/components/ui/tool-block";
import { TodoBlock } from "@/components/ui/todo-block";
import { ListTodo } from "lucide-react";
import { useGlobalState } from "@/app/contexts/GlobalState";
import type { ChatStatus } from "@/types";

interface TodoToolHandlerProps {
message: UIMessage;
part: any;
status: ChatStatus;
}

interface Todo {
id: string;
content: string;
status: "pending" | "in_progress" | "completed" | "cancelled";
}

export const TodoToolHandler = ({
message,
part,
status,
}: TodoToolHandlerProps) => {
const { toolCallId, state, input, output } = part;
const { setTodos } = useGlobalState();

// Handle tool-todoWrite type
const todoInput = input as {
merge: boolean;
todos: Todo[];
};

// Update global todos state when output is available
useEffect(() => {
if (state === "output-available" && output?.currentTodos) {
setTodos(output.currentTodos);
}
}, [state, output, setTodos]);

switch (state) {
case "input-streaming":
return status === "streaming" ? (
<ToolBlock
key={toolCallId}
icon={<ListTodo />}
action="Creating to-do list"
isShimmer={true}
/>
) : null;

case "input-available":
return status === "streaming" ? (
<ToolBlock
key={toolCallId}
icon={<ListTodo />}
action={
todoInput?.merge ? "Updating to-do list" : "Creating to-do list"
}
target={`${todoInput?.todos?.length || 0} items`}
isShimmer={true}
/>
) : null;

case "output-available": {
const todoOutput = output as {
result: string;
counts: {
completed: number;
total: number;
};
currentTodos: Todo[];
};

return (
<TodoBlock
todos={todoOutput.currentTodos}
inputTodos={todoInput?.todos}
blockId={toolCallId}
messageId={message.id}
/>
);
}

default:
return null;
}
};
3 changes: 2 additions & 1 deletion app/components/tools/WebSearchToolHandler.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import ToolBlock from "@/components/ui/tool-block";
import { Search } from "lucide-react";
import type { ChatStatus } from "@/types";

interface WebSearchToolHandlerProps {
part: any;
status: "ready" | "submitted" | "streaming" | "error";
status: ChatStatus;
}

export const WebSearchToolHandler = ({
Expand Down
Loading