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
37 changes: 8 additions & 29 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { getUserID } from "@/lib/auth/server";
import { generateTitleFromUserMessage } from "@/lib/actions";
import { NextRequest } from "next/server";
import { myProvider } from "@/lib/ai/providers";
import type { ChatMode, ExecutionMode } from "@/types";
import type { ChatMode, ExecutionMode, Todo } from "@/types";
import { checkRateLimit } from "@/lib/rate-limit";
import { ChatSDKError } from "@/lib/errors";
import PostHogClient from "@/app/posthog";
Expand All @@ -26,7 +26,11 @@ export const maxDuration = 300;

export async function POST(req: NextRequest) {
try {
const { messages, mode }: { messages: UIMessage[]; mode: ChatMode } =
const {
messages,
mode,
todos,
}: { messages: UIMessage[]; mode: ChatMode; todos?: Todo[] } =
await req.json();

// Get user ID from authenticated session or fallback to anonymous
Expand Down Expand Up @@ -56,22 +60,13 @@ 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,
writer,
mode,
executionMode,
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",
todos,
);

// Generate title in parallel if this is the start of a conversation
Expand Down Expand Up @@ -101,31 +96,15 @@ export async function POST(req: NextRequest) {

const result = streamText({
model: model,
system: systemPrompt(model.modelId, executionMode),
system: systemPrompt(model.modelId, mode, 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
17 changes: 8 additions & 9 deletions app/components/MessagePartHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,24 +43,23 @@ export const MessagePartHandler = ({
case "text":
return renderTextPart();

case "tool-readFile":
case "tool-writeFile":
case "tool-deleteFile":
case "tool-searchReplace":
case "tool-multiEdit":
case "tool-read_file":
case "tool-write_file":
case "tool-delete_file":
case "tool-search_replace":
case "tool-multi_edit":
return <FileToolsHandler part={part} status={status} />;

case "tool-webSearch":
case "tool-web_search":
return <WebSearchToolHandler part={part} status={status} />;

case "data-terminal":
case "tool-runTerminalCmd":
case "tool-run_terminal_cmd":
return (
<TerminalToolHandler message={message} part={part} status={status} />
);

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

default:
Expand Down
42 changes: 32 additions & 10 deletions app/components/ScrollToBottomButton.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,46 @@
import { ChevronDown } from "lucide-react";
import { useGlobalState } from "@/app/contexts/GlobalState";
import { getTodoStats } from "@/lib/utils/todo-utils";

interface ScrollToBottomButtonProps {
isVisible: boolean;
onClick: () => void;
hasMessages: boolean;
isAtBottom: boolean;
}

export const ScrollToBottomButton = ({
isVisible,
onClick,
hasMessages,
isAtBottom,
}: ScrollToBottomButtonProps) => {
if (!isVisible) return null;
const { sidebarOpen, isTodoPanelExpanded, todos } = useGlobalState();

const shouldShowScrollButton =
hasMessages && !isAtBottom && !isTodoPanelExpanded;

if (!shouldShowScrollButton) return null;

// Check if there are any active todos to determine positioning (same logic as TodoPanel)
const stats = getTodoStats(todos);
const hasActiveTodos = stats.inProgress > 0 || stats.pending > 0;
const bottomPosition = hasActiveTodos ? "bottom-42" : "bottom-34";

return (
<button
onClick={onClick}
className="bg-background border border-border rounded-full p-2 shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-105 flex items-center justify-center"
aria-label="Scroll to bottom"
tabIndex={0}
<div
className={`fixed ${bottomPosition} z-40 transition-all duration-300 ${
sidebarOpen
? "left-1/2 desktop:left-1/4 -translate-x-1/2"
: "left-1/2 -translate-x-1/2"
}`}
>
<ChevronDown className="w-4 h-4 text-muted-foreground" />
</button>
<button
onClick={onClick}
className="bg-background border border-border rounded-full p-2 shadow-lg hover:shadow-xl transition-all duration-200 hover:scale-105 flex items-center justify-center"
aria-label="Scroll to bottom"
tabIndex={0}
>
<ChevronDown className="w-4 h-4 text-muted-foreground" />
</button>
</div>
);
};
48 changes: 24 additions & 24 deletions app/components/TodoPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,46 @@
"use client";

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

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 { todos, setIsTodoPanelExpanded } = useGlobalState();
const stats = getTodoStats(todos);

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

// Reflect expansion to global state
useEffect(() => {
setIsTodoPanelExpanded(isExpanded);
return () => {
setIsTodoPanelExpanded(false);
};
}, [isExpanded, setIsTodoPanelExpanded]);

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

// If panel is not visible, ensure global state is reset
useEffect(() => {
if (!hasTodos || !hasActiveTodos) {
setIsTodoPanelExpanded(false);
}
}, [hasTodos, hasActiveTodos, setIsTodoPanelExpanded]);

if (!hasTodos) {
return null;
}

if (!hasActiveTodos) {
return null;
}
Expand Down
13 changes: 8 additions & 5 deletions app/components/tools/FileToolsHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ export const FileToolsHandler = ({ part, status }: FileToolsHandlerProps) => {
if (!readInput.offset && readInput.limit) {
return ` L1-${readInput.limit}`;
}
if (readInput.offset && !readInput.limit) {
return ` L${readInput.offset}+`;
}
return "";
};

Expand Down Expand Up @@ -305,15 +308,15 @@ export const FileToolsHandler = ({ part, status }: FileToolsHandlerProps) => {

// Main switch for file tool types
switch (part.type) {
case "tool-readFile":
case "tool-read_file":
return renderReadFileTool();
case "tool-writeFile":
case "tool-write_file":
return renderWriteFileTool();
case "tool-deleteFile":
case "tool-delete_file":
return renderDeleteFileTool();
case "tool-searchReplace":
case "tool-search_replace":
return renderSearchReplaceTool();
case "tool-multiEdit":
case "tool-multi_edit":
return renderMultiEditTool();
default:
return null;
Expand Down
26 changes: 3 additions & 23 deletions app/components/tools/TodoToolHandler.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,23 @@
import React, { useEffect } from "react";
import React 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";
import type { ChatStatus, Todo, TodoWriteInput } 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]);
const todoInput = input as TodoWriteInput;

switch (state) {
case "input-streaming":
Expand Down
24 changes: 23 additions & 1 deletion app/contexts/GlobalState.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
"use client";

import React, { createContext, useContext, useState, ReactNode } from "react";
import React, {
createContext,
useContext,
useState,
useCallback,
ReactNode,
} from "react";
import type { ChatMode, SidebarContent } from "@/types/chat";
import type { Todo } from "@/types";
import { mergeTodos as mergeTodosUtil } from "@/lib/utils/todo-utils";

interface GlobalStateType {
// Input state
Expand All @@ -26,6 +33,11 @@ interface GlobalStateType {
// Todos state
todos: Todo[];
setTodos: (todos: Todo[]) => void;
mergeTodos: (todos: Todo[]) => void;

// UI state
isTodoPanelExpanded: boolean;
setIsTodoPanelExpanded: (expanded: boolean) => void;

// Utility methods
clearInput: () => void;
Expand Down Expand Up @@ -55,6 +67,11 @@ export const GlobalStateProvider: React.FC<GlobalStateProviderProps> = ({
);
const [todos, setTodos] = useState<Todo[]>([]);

const mergeTodos = useCallback((newTodos: Todo[]) => {
setTodos((currentTodos) => mergeTodosUtil(currentTodos, newTodos));
}, []);
const [isTodoPanelExpanded, setIsTodoPanelExpanded] = useState(false);

const clearInput = () => {
setInput("");
};
Expand All @@ -63,6 +80,7 @@ export const GlobalStateProvider: React.FC<GlobalStateProviderProps> = ({
setInput("");
setChatTitle(null);
setTodos([]);
setIsTodoPanelExpanded(false);
};

const openSidebar = (content: SidebarContent) => {
Expand Down Expand Up @@ -97,6 +115,10 @@ export const GlobalStateProvider: React.FC<GlobalStateProviderProps> = ({
setSidebarContent,
todos,
setTodos,
mergeTodos,

isTodoPanelExpanded,
setIsTodoPanelExpanded,

clearInput,
resetChat,
Expand Down
Loading