Skip to content
Open
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
7 changes: 7 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "all",
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
"trailingComma": "all",
"trailingComma": "all",

Please switch our biome config

"semi": true,
"printWidth": 80
}
7 changes: 5 additions & 2 deletions src/app/(chat)/project/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
FileUp,
Pencil,
MessagesSquare,
GitBranch,
} from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
Expand Down Expand Up @@ -225,8 +226,10 @@ export default function ProjectPage() {
>
<MessagesSquare size={16} className="text-primary" />
<div className="flex-1 truncate">
<div className="font-medium truncate">
{thread.title}
<div className="font-medium truncate flex items-center">
{thread.parentThreadId && (
<GitBranch className="mr-2 h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
{thread.title}
</div>
</div>
Expand Down
71 changes: 69 additions & 2 deletions src/app/api/chat/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import {
generateExampleToolSchemaPrompt,
} from "lib/ai/prompts";

import type { ChatModel, ChatThread, Project } from "app-types/chat";
import type {
ChatMessage,
ChatModel,
ChatThread,
Project,
} from "app-types/chat";

import {
chatRepository,
Expand All @@ -31,6 +36,7 @@ import logger from "logger";
import { JSONSchema7 } from "json-schema";
import { ObjectJsonSchema7 } from "app-types/util";
import { jsonSchemaToZod } from "lib/json-schema-to-zod";
import { randomUUID } from "crypto";

export async function getUserId() {
const session = await getSession();
Expand All @@ -44,7 +50,10 @@ export async function getUserId() {
export async function generateTitleFromUserMessageAction({
message,
model,
}: { message: Message; model: LanguageModel }) {
}: {
message: Message;
model: LanguageModel;
}) {
await getSession();
const prompt = toAny(message.parts?.at(-1))?.text || "unknown";

Expand Down Expand Up @@ -87,6 +96,64 @@ export async function deleteMessagesByChatIdAfterTimestampAction(
await chatRepository.deleteMessagesByChatIdAfterTimestamp(messageId);
}

export async function branchOutAction(
threadId: string,
messageId: string,
): Promise<{
id: string;
}> {
const userId = await getUserId();
console.log("userId", userId);

if (!userId) {
throw new Error("User not found");
}

const threadDetails = await chatRepository.selectThreadDetails(threadId);
if (!threadDetails) {
throw new Error("Thread not found");
}

const isMessageInThread = threadDetails.messages.some(
(message) => message.id === messageId,
);
if (!isMessageInThread) {
throw new Error("Message not found in thread");
}

const messagesForNewThread: ChatMessage[] = [];

for (const message of threadDetails.messages) {
if (message.id === messageId) {
messagesForNewThread.push(message);
break;
}
messagesForNewThread.push(message);
}

const newThread = await chatRepository.insertThread({
title: `Branch - ${threadDetails.title}`,
userId: threadDetails.userId,
projectId: threadDetails.projectId,
id: randomUUID(),
parentThreadId: threadDetails.id,
});

await chatRepository.insertMessages(
messagesForNewThread.map((message) => ({
role: message.role,
parts: message.parts,
model: message.model,
attachments: message.attachments,
annotations: message.annotations,
threadId: newThread.id,
id: randomUUID(),
})),
);

return { id: newThread.id };
}

export async function selectThreadListByUserIdAction() {
const userId = await getUserId();
const threads = await chatRepository.selectThreadsByUserId(userId);
Expand Down
14 changes: 14 additions & 0 deletions src/app/api/chat/branch/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { NextRequest } from "next/server";

export async function POST(_request: NextRequest) {
try {
} catch (error: any) {
console.error("Error:", error);
return new Response(
JSON.stringify({ error: error.message || "Internal Server Error" }),
{
status: 500,
},
);
}
}
4 changes: 4 additions & 0 deletions src/components/layouts/app-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ChevronRight,
MessageCircleDashed,
PanelLeft,
GitBranch,
} from "lucide-react";
import { Button } from "ui/button";
import { Separator } from "ui/separator";
Expand Down Expand Up @@ -187,6 +188,9 @@ function ThreadDropdownComponent() {
variant="ghost"
className="data-[state=open]:bg-input! hover:text-foreground cursor-pointer flex gap-1 items-center px-2 py-1 rounded-md hover:bg-accent"
>
{currentThread.parentThreadId && (
<GitBranch className="mr-1 h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
<p className="truncate max-w-60 min-w-0">{currentThread.title}</p>

<ChevronDown size={14} />
Expand Down
11 changes: 10 additions & 1 deletion src/components/layouts/app-sidebar-threads.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import {
import { SidebarGroupContent, SidebarMenu, SidebarMenuItem } from "ui/sidebar";
import { SidebarGroup } from "ui/sidebar";
import { ThreadDropdown } from "../thread-dropdown";
import { ChevronDown, ChevronUp, MoreHorizontal, Trash } from "lucide-react";
import {
ChevronDown,
ChevronUp,
MoreHorizontal,
Trash,
GitBranch,
} from "lucide-react";
import { useMounted } from "@/hooks/use-mounted";
import { appStore } from "@/app/store";
import { Button } from "ui/button";
Expand Down Expand Up @@ -234,6 +240,9 @@ export function AppSidebarThreads() {
href={`/chat/${thread.id}`}
className="flex items-center"
>
{thread.parentThreadId && (
<GitBranch className="mr-2 h-3 w-3 text-muted-foreground flex-shrink-0" />
)}
<p className="truncate ">{thread.title}</p>
</Link>
</SidebarMenuButton>
Expand Down
38 changes: 38 additions & 0 deletions src/components/message-parts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
Loader2,
AlertTriangleIcon,
Percent,
GitBranch,
HammerIcon,
} from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip";
Expand All @@ -39,6 +40,7 @@ import { useCopy } from "@/hooks/use-copy";
import { AnimatePresence, motion } from "framer-motion";
import { SelectModel } from "./select-model";
import {
branchOutAction,
deleteMessageAction,
deleteMessagesByChatIdAfterTimestampAction,
} from "@/app/api/chat/actions";
Expand Down Expand Up @@ -75,6 +77,7 @@ import { TavilyResponse } from "lib/ai/tools/web/web-search";

import { CodeBlock } from "ui/CodeBlock";
import { SafeJsExecutionResult, safeJsRun } from "lib/safe-js-run";
import { useRouter } from "next/navigation";

type MessagePart = UIMessage["parts"][number];

Expand Down Expand Up @@ -247,6 +250,9 @@ export const AssistMessagePart = memo(function AssistMessagePart({
const { copied, copy } = useCopy();
const [isLoading, setIsLoading] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isBranching, setIsBranching] = useState(false);

const router = useRouter();

const deleteMessage = useCallback(() => {
safe(() => setIsDeleting(true))
Expand Down Expand Up @@ -295,6 +301,20 @@ export const AssistMessagePart = memo(function AssistMessagePart({
.unwrap();
};

const handleBranchOut = useCallback(async () => {
safe(() => setIsBranching(true))
.ifOk(async () => {
if (!threadId) {
throw new Error("Thread ID is required");
}
const newThread = await branchOutAction(threadId, message.id);
router.push(`/chat/${newThread.id}`);
})
.ifFail((error) => toast.error(error.message))
.watch(() => setIsBranching(false))
.unwrap();
}, [message.id]);

return (
<div
className={cn(isLoading && "animate-pulse", "flex flex-col gap-2 group")}
Expand Down Expand Up @@ -344,6 +364,24 @@ export const AssistMessagePart = memo(function AssistMessagePart({
</TooltipTrigger>
<TooltipContent>Change Model</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isBranching}
onClick={handleBranchOut}
className="size-3! p-4! opacity-0 group-hover/message:opacity-100"
>
{isBranching ? (
<Loader className="animate-spin" />
) : (
<GitBranch />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Branch Out</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
Expand Down
56 changes: 1 addition & 55 deletions src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export default function PromptInput({
[setModel, appStoreMutate],
);

const deleteMention = useCallback(
const _deleteMention = useCallback(
(mention: ChatMention) => {
if (!threadId) return;
appStoreMutate((prev) => {
Expand Down Expand Up @@ -179,60 +179,6 @@ export default function PromptInput({
<div className="z-10 mx-auto w-full max-w-3xl relative">
<fieldset className="flex w-full min-w-0 max-w-full flex-col px-2">
<div className="ring-8 ring-muted/60 overflow-hidden rounded-4xl backdrop-blur-sm transition-all duration-200 bg-muted/60 relative flex w-full flex-col cursor-text z-10 items-stretch focus-within:bg-muted hover:bg-muted focus-within:ring-muted hover:ring-muted">
{mentions.length > 0 && (
<div className="bg-input rounded-b-sm rounded-t-3xl p-3 flex flex-col gap-4">
{mentions.map((mention, i) => {
return (
<div key={i} className="flex items-center gap-2">
{mention.type === "workflow" ? (
<Avatar
className="size-6 p-1 ring ring-border rounded-full flex-shrink-0"
style={mention.icon?.style}
>
<AvatarImage src={mention.icon?.value} />
<AvatarFallback>
{mention.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
) : (
<Button className="size-6 flex items-center justify-center ring ring-border rounded-full flex-shrink-0 p-0.5">
{mention.type == "mcpServer" ? (
<MCPIcon className="size-3.5" />
) : (
<DefaultToolIcon
name={mention.name as DefaultToolName}
className="size-3.5"
/>
)}
</Button>
)}

<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm font-semibold truncate">
{mention.name}
</span>
{mention.description ? (
<span className="text-muted-foreground text-xs truncate">
{mention.description}
</span>
) : null}
</div>
<Button
variant={"ghost"}
size={"icon"}
disabled={!threadId}
className="rounded-full hover:bg-input! flex-shrink-0"
onClick={() => {
deleteMention(mention);
}}
>
<XIcon />
</Button>
</div>
);
})}
</div>
)}
<div className="flex flex-col gap-3.5 px-3 py-2">
<div className="relative min-h-[2rem]">
<ChatMentionInput
Expand Down
2 changes: 1 addition & 1 deletion src/lib/ai/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const staticModels = {
},
google: {
"gemini-2.0-flash-lite": google("gemini-2.0-flash-lite"),
"gemini-2.5-flash": google("gemini-2.5-flash-preview-04-17"),
"gemini-2.5-flash": google("gemini-2.5-flash"),
"gemini-2.5-pro": google("gemini-2.5-pro-preview-05-06"),
},
anthropic: {
Expand Down
1 change: 1 addition & 0 deletions src/lib/db/migrations/pg/0008_amazing_chat.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "chat_thread" ADD COLUMN "parent_thread_id" uuid;
Loading