mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-19 20:18:22 -05:00
Compare commits
10 Commits
fix/undefi
...
fix/fronte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36568d358e | ||
|
|
484321376c | ||
|
|
93651621e6 | ||
|
|
1bdaa0a8c8 | ||
|
|
e7c5294b24 | ||
|
|
82ae0303cf | ||
|
|
a37f7efbdf | ||
|
|
e2ae6086c9 | ||
|
|
1108f74359 | ||
|
|
9b98b2df40 |
14
AGENTS.md
14
AGENTS.md
@@ -16,6 +16,20 @@ See `docs/content/platform/getting-started.md` for setup instructions.
|
||||
- Format Python code with `poetry run format`.
|
||||
- Format frontend code using `pnpm format`.
|
||||
|
||||
## Frontend-specific guidelines
|
||||
|
||||
**When working on files in `autogpt_platform/frontend/`, always read and follow the conventions in `autogpt_platform/frontend/CONTRIBUTING.md`.**
|
||||
|
||||
Key frontend conventions:
|
||||
- Component props should be `interface Props { ... }` (not exported) unless the interface needs to be used outside the component
|
||||
- Separate render logic from business logic (component.tsx + useComponent.ts + helpers.ts)
|
||||
- Colocate state when possible and avoid create large components, use sub-components ( local `/components` folder next to the parent component ) when sensible
|
||||
- Avoid large hooks, abstract logic into `helpers.ts` files when sensible
|
||||
- Use function declarations for components and handlers, arrow functions only for small inline callbacks
|
||||
- No barrel files or `index.ts` re-exports
|
||||
|
||||
See `autogpt_platform/frontend/CONTRIBUTING.md` for complete frontend architecture, patterns, and conventions.
|
||||
|
||||
## Testing
|
||||
|
||||
- Backend: `poetry run test` (runs pytest with a docker based postgres + prisma).
|
||||
|
||||
@@ -549,9 +549,48 @@ Files:
|
||||
Types:
|
||||
|
||||
- Prefer `interface` for object shapes
|
||||
- Component props should be `interface Props { ... }`
|
||||
- Component props should be `interface Props { ... }` (not exported)
|
||||
- Only use specific exported names (e.g., `export interface MyComponentProps`) when the interface needs to be used outside the component
|
||||
- Keep type definitions inline with the component - do not create separate `types.ts` files unless types are shared across multiple files
|
||||
- Use precise types; avoid `any` and unsafe casts
|
||||
|
||||
**Props naming examples:**
|
||||
|
||||
```tsx
|
||||
// ✅ Good - internal props, not exported
|
||||
interface Props {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function Modal({ title, onClose }: Props) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ Good - exported when needed externally
|
||||
export interface ModalProps {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function Modal({ title, onClose }: ModalProps) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ❌ Bad - unnecessarily specific name for internal use
|
||||
interface ModalComponentProps {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ❌ Bad - separate types.ts file for single component
|
||||
// types.ts
|
||||
export interface ModalProps { ... }
|
||||
|
||||
// Modal.tsx
|
||||
import type { ModalProps } from './types';
|
||||
```
|
||||
|
||||
Parameters:
|
||||
|
||||
- If more than one parameter is needed, pass a single `Args` object for clarity
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const LOGOUT_REDIRECT_DELAY_MS = 400;
|
||||
|
||||
function wait(ms: number): Promise<void> {
|
||||
return new Promise(function resolveAfterDelay(resolve) {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
export default function LogoutPage() {
|
||||
const { logOut } = useSupabase();
|
||||
const router = useRouter();
|
||||
const hasStartedRef = useRef(false);
|
||||
|
||||
useEffect(function handleLogoutEffect() {
|
||||
if (hasStartedRef.current) return;
|
||||
hasStartedRef.current = true;
|
||||
|
||||
async function runLogout() {
|
||||
await logOut();
|
||||
await wait(LOGOUT_REDIRECT_DELAY_MS);
|
||||
router.replace("/login");
|
||||
}
|
||||
|
||||
void runLogout();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4">
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||
<LoadingSpinner size="large" />
|
||||
<Text variant="body" className="text-center">
|
||||
Logging you out...
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export async function GET(request: Request) {
|
||||
const { searchParams, origin } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
|
||||
let next = "/marketplace";
|
||||
let next = "/";
|
||||
|
||||
if (code) {
|
||||
const supabase = await getServerSupabase();
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatMessage } from "../ChatMessage/ChatMessage";
|
||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||
import { StreamingMessage } from "../StreamingMessage/StreamingMessage";
|
||||
import { ThinkingMessage } from "../ThinkingMessage/ThinkingMessage";
|
||||
import { useMessageList } from "./useMessageList";
|
||||
|
||||
export interface MessageListProps {
|
||||
messages: ChatMessageData[];
|
||||
streamingChunks?: string[];
|
||||
isStreaming?: boolean;
|
||||
className?: string;
|
||||
onStreamComplete?: () => void;
|
||||
onSendMessage?: (content: string) => void;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
streamingChunks = [],
|
||||
isStreaming = false,
|
||||
className,
|
||||
onStreamComplete,
|
||||
onSendMessage,
|
||||
}: MessageListProps) {
|
||||
const { messagesEndRef, messagesContainerRef } = useMessageList({
|
||||
messageCount: messages.length,
|
||||
isStreaming,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className={cn(
|
||||
"flex-1 overflow-y-auto",
|
||||
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex max-w-3xl flex-col py-4">
|
||||
{/* Render all persisted messages */}
|
||||
{messages.map((message, index) => {
|
||||
// Check if current message is an agent_output tool_response
|
||||
// and if previous message is an assistant message
|
||||
let agentOutput: ChatMessageData | undefined;
|
||||
|
||||
if (message.type === "tool_response" && message.result) {
|
||||
let parsedResult: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsedResult =
|
||||
typeof message.result === "string"
|
||||
? JSON.parse(message.result)
|
||||
: (message.result as Record<string, unknown>);
|
||||
} catch {
|
||||
parsedResult = null;
|
||||
}
|
||||
if (parsedResult?.type === "agent_output") {
|
||||
const prevMessage = messages[index - 1];
|
||||
if (
|
||||
prevMessage &&
|
||||
prevMessage.type === "message" &&
|
||||
prevMessage.role === "assistant"
|
||||
) {
|
||||
// This agent output will be rendered inside the previous assistant message
|
||||
// Skip rendering this message separately
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if next message is an agent_output tool_response to include in current assistant message
|
||||
if (message.type === "message" && message.role === "assistant") {
|
||||
const nextMessage = messages[index + 1];
|
||||
if (
|
||||
nextMessage &&
|
||||
nextMessage.type === "tool_response" &&
|
||||
nextMessage.result
|
||||
) {
|
||||
let parsedResult: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsedResult =
|
||||
typeof nextMessage.result === "string"
|
||||
? JSON.parse(nextMessage.result)
|
||||
: (nextMessage.result as Record<string, unknown>);
|
||||
} catch {
|
||||
parsedResult = null;
|
||||
}
|
||||
if (parsedResult?.type === "agent_output") {
|
||||
agentOutput = nextMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
key={index}
|
||||
message={message}
|
||||
onSendMessage={onSendMessage}
|
||||
agentOutput={agentOutput}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Render thinking message when streaming but no chunks yet */}
|
||||
{isStreaming && streamingChunks.length === 0 && <ThinkingMessage />}
|
||||
|
||||
{/* Render streaming message if active */}
|
||||
{isStreaming && streamingChunks.length > 0 && (
|
||||
<StreamingMessage
|
||||
chunks={streamingChunks}
|
||||
onComplete={onStreamComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Invisible div to scroll to */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WrenchIcon } from "@phosphor-icons/react";
|
||||
import { getToolActionPhrase } from "../../helpers";
|
||||
|
||||
export interface ToolCallMessageProps {
|
||||
toolName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolCallMessage({ toolName, className }: ToolCallMessageProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center gap-2", className)}>
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{getToolActionPhrase(toolName)}...
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import "@/components/contextual/OutputRenderers";
|
||||
import {
|
||||
globalRegistry,
|
||||
OutputItem,
|
||||
} from "@/components/contextual/OutputRenderers";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolResult } from "@/types/chat";
|
||||
import { WrenchIcon } from "@phosphor-icons/react";
|
||||
import { getToolActionPhrase } from "../../helpers";
|
||||
|
||||
export interface ToolResponseMessageProps {
|
||||
toolName: string;
|
||||
result?: ToolResult;
|
||||
success?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolResponseMessage({
|
||||
toolName,
|
||||
result,
|
||||
success: _success = true,
|
||||
className,
|
||||
}: ToolResponseMessageProps) {
|
||||
if (!result) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center gap-2", className)}>
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{getToolActionPhrase(toolName)}...
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let parsedResult: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsedResult =
|
||||
typeof result === "string"
|
||||
? JSON.parse(result)
|
||||
: (result as Record<string, unknown>);
|
||||
} catch {
|
||||
parsedResult = null;
|
||||
}
|
||||
|
||||
if (parsedResult && typeof parsedResult === "object") {
|
||||
const responseType = parsedResult.type as string | undefined;
|
||||
|
||||
if (responseType === "agent_output") {
|
||||
const execution = parsedResult.execution as
|
||||
| {
|
||||
outputs?: Record<string, unknown[]>;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const outputs = execution?.outputs || {};
|
||||
const message = parsedResult.message as string | undefined;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4 px-4 py-2", className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{getToolActionPhrase(toolName)}
|
||||
</Text>
|
||||
</div>
|
||||
{message && (
|
||||
<div className="rounded border p-4">
|
||||
<Text variant="small" className="text-neutral-600">
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{Object.keys(outputs).length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(outputs).map(([outputName, values]) =>
|
||||
values.map((value, index) => {
|
||||
const renderer = globalRegistry.getRenderer(value);
|
||||
if (renderer) {
|
||||
return (
|
||||
<OutputItem
|
||||
key={`${outputName}-${index}`}
|
||||
value={value}
|
||||
renderer={renderer}
|
||||
label={outputName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`${outputName}-${index}`}
|
||||
className="rounded border p-4"
|
||||
>
|
||||
<Text variant="large-medium" className="mb-2 capitalize">
|
||||
{outputName}
|
||||
</Text>
|
||||
<pre className="overflow-auto text-sm">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (responseType === "block_output" && parsedResult.outputs) {
|
||||
const outputs = parsedResult.outputs as Record<string, unknown[]>;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4 px-4 py-2", className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{getToolActionPhrase(toolName)}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{Object.entries(outputs).map(([outputName, values]) =>
|
||||
values.map((value, index) => {
|
||||
const renderer = globalRegistry.getRenderer(value);
|
||||
if (renderer) {
|
||||
return (
|
||||
<OutputItem
|
||||
key={`${outputName}-${index}`}
|
||||
value={value}
|
||||
renderer={renderer}
|
||||
label={outputName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`${outputName}-${index}`}
|
||||
className="rounded border p-4"
|
||||
>
|
||||
<Text variant="large-medium" className="mb-2 capitalize">
|
||||
{outputName}
|
||||
</Text>
|
||||
<pre className="overflow-auto text-sm">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle other response types with a message field (e.g., understanding_updated)
|
||||
if (parsedResult.message && typeof parsedResult.message === "string") {
|
||||
// Format tool name from snake_case to Title Case
|
||||
const formattedToolName = toolName
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
|
||||
// Clean up message - remove incomplete user_name references
|
||||
let cleanedMessage = parsedResult.message;
|
||||
// Remove "Updated understanding with: user_name" pattern if user_name is just a placeholder
|
||||
cleanedMessage = cleanedMessage.replace(
|
||||
/Updated understanding with:\s*user_name\.?\s*/gi,
|
||||
"",
|
||||
);
|
||||
// Remove standalone user_name references
|
||||
cleanedMessage = cleanedMessage.replace(/\buser_name\b\.?\s*/gi, "");
|
||||
cleanedMessage = cleanedMessage.trim();
|
||||
|
||||
// Only show message if it has content after cleaning
|
||||
if (!cleanedMessage) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2 px-4 py-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{formattedToolName}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2 px-4 py-2", className)}>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{formattedToolName}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="rounded border p-4">
|
||||
<Text variant="small" className="text-neutral-600">
|
||||
{cleanedMessage}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const renderer = globalRegistry.getRenderer(result);
|
||||
if (renderer) {
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{getToolActionPhrase(toolName)}
|
||||
</Text>
|
||||
</div>
|
||||
<OutputItem value={result} renderer={renderer} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center gap-2", className)}>
|
||||
<WrenchIcon
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="flex-shrink-0 text-neutral-500"
|
||||
/>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{getToolActionPhrase(toolName)}...
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { Chat } from "./components/Chat/Chat";
|
||||
|
||||
export default function ChatPage() {
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (isChatEnabled === false) {
|
||||
router.push("/marketplace");
|
||||
}
|
||||
}, [isChatEnabled, router]);
|
||||
|
||||
if (isChatEnabled === null || isChatEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<Chat className="flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { Chat } from "@/components/contextual/Chat/Chat";
|
||||
import { useCopilotChatPage } from "./useCopilotChatPage";
|
||||
|
||||
export default function CopilotChatPage() {
|
||||
const { isFlagReady, isChatEnabled, sessionId, prompt } =
|
||||
useCopilotChatPage();
|
||||
|
||||
if (!isFlagReady || isChatEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<Chat
|
||||
className="flex-1"
|
||||
urlSessionId={sessionId}
|
||||
initialPrompt={prompt}
|
||||
showNewChatButton={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { getHomepageRoute } from "@/lib/constants";
|
||||
import {
|
||||
Flag,
|
||||
type FlagValues,
|
||||
useGetFlag,
|
||||
} from "@/services/feature-flags/use-get-flag";
|
||||
import { useFlags } from "launchdarkly-react-client-sdk";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useCopilotChatPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
const flags = useFlags<FlagValues>();
|
||||
const homepageRoute = getHomepageRoute(isChatEnabled);
|
||||
const envEnabled = process.env.NEXT_PUBLIC_LAUNCHDARKLY_ENABLED === "true";
|
||||
const clientId = process.env.NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID;
|
||||
const isLaunchDarklyConfigured = envEnabled && Boolean(clientId);
|
||||
const isFlagReady =
|
||||
!isLaunchDarklyConfigured || flags[Flag.CHAT] !== undefined;
|
||||
|
||||
const sessionId = searchParams.get("sessionId");
|
||||
const prompt = searchParams.get("prompt");
|
||||
|
||||
useEffect(
|
||||
function guardAccess() {
|
||||
if (!isFlagReady) return;
|
||||
if (isChatEnabled === false) {
|
||||
router.replace(homepageRoute);
|
||||
}
|
||||
},
|
||||
[homepageRoute, isChatEnabled, isFlagReady, router],
|
||||
);
|
||||
|
||||
return {
|
||||
isFlagReady,
|
||||
isChatEnabled,
|
||||
sessionId,
|
||||
prompt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { NAVBAR_HEIGHT_PX } from "@/lib/constants";
|
||||
import type { ReactNode } from "react";
|
||||
import { DesktopSidebar } from "./components/DesktopSidebar/DesktopSidebar";
|
||||
import { LoadingState } from "./components/LoadingState/LoadingState";
|
||||
import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer";
|
||||
import { MobileHeader } from "./components/MobileHeader/MobileHeader";
|
||||
import { useCopilotShell } from "./useCopilotShell";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function CopilotShell({ children }: Props) {
|
||||
const {
|
||||
isMobile,
|
||||
isDrawerOpen,
|
||||
isLoading,
|
||||
sessions,
|
||||
currentSessionId,
|
||||
handleSelectSession,
|
||||
handleOpenDrawer,
|
||||
handleCloseDrawer,
|
||||
handleDrawerOpenChange,
|
||||
handleNewChat,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
isReadyToShowContent,
|
||||
} = useCopilotShell();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex overflow-hidden bg-zinc-50"
|
||||
style={{ height: `calc(100vh - ${NAVBAR_HEIGHT_PX}px)` }}
|
||||
>
|
||||
{!isMobile && (
|
||||
<DesktopSidebar
|
||||
sessions={sessions}
|
||||
currentSessionId={currentSessionId}
|
||||
isLoading={isLoading}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onSelectSession={handleSelectSession}
|
||||
onFetchNextPage={fetchNextPage}
|
||||
onNewChat={handleNewChat}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{isMobile && <MobileHeader onOpenDrawer={handleOpenDrawer} />}
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{isReadyToShowContent ? children : <LoadingState />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<MobileDrawer
|
||||
isOpen={isDrawerOpen}
|
||||
sessions={sessions}
|
||||
currentSessionId={currentSessionId}
|
||||
isLoading={isLoading}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onSelectSession={handleSelectSession}
|
||||
onFetchNextPage={fetchNextPage}
|
||||
onNewChat={handleNewChat}
|
||||
onClose={handleCloseDrawer}
|
||||
onOpenChange={handleDrawerOpenChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { scrollbarStyles } from "@/components/styles/scrollbars";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Plus } from "@phosphor-icons/react";
|
||||
import { SessionsList } from "../SessionsList/SessionsList";
|
||||
|
||||
interface Props {
|
||||
sessions: SessionSummaryResponse[];
|
||||
currentSessionId: string | null;
|
||||
isLoading: boolean;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onFetchNextPage: () => void;
|
||||
onNewChat: () => void;
|
||||
}
|
||||
|
||||
export function DesktopSidebar({
|
||||
sessions,
|
||||
currentSessionId,
|
||||
isLoading,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onSelectSession,
|
||||
onFetchNextPage,
|
||||
onNewChat,
|
||||
}: Props) {
|
||||
return (
|
||||
<aside className="flex h-full w-80 flex-col border-r border-zinc-100 bg-white">
|
||||
<div className="shrink-0 px-6 py-4">
|
||||
<Text variant="h3" size="body-medium">
|
||||
Your chats
|
||||
</Text>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col overflow-y-auto px-3 py-3",
|
||||
scrollbarStyles,
|
||||
)}
|
||||
>
|
||||
<SessionsList
|
||||
sessions={sessions}
|
||||
currentSessionId={currentSessionId}
|
||||
isLoading={isLoading}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onSelectSession={onSelectSession}
|
||||
onFetchNextPage={onFetchNextPage}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 bg-white p-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={onNewChat}
|
||||
className="w-full"
|
||||
leftIcon={<Plus width="1rem" height="1rem" />}
|
||||
>
|
||||
New Chat
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { ChatLoader } from "@/components/contextual/Chat/components/ChatLoader/ChatLoader";
|
||||
|
||||
export function LoadingState() {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<ChatLoader />
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
Loading your chats...
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { scrollbarStyles } from "@/components/styles/scrollbars";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Plus, X } from "@phosphor-icons/react";
|
||||
import { Drawer } from "vaul";
|
||||
import { SessionsList } from "../SessionsList/SessionsList";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
sessions: SessionSummaryResponse[];
|
||||
currentSessionId: string | null;
|
||||
isLoading: boolean;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onFetchNextPage: () => void;
|
||||
onNewChat: () => void;
|
||||
onClose: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function MobileDrawer({
|
||||
isOpen,
|
||||
sessions,
|
||||
currentSessionId,
|
||||
isLoading,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onSelectSession,
|
||||
onFetchNextPage,
|
||||
onNewChat,
|
||||
onClose,
|
||||
onOpenChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<Drawer.Root open={isOpen} onOpenChange={onOpenChange} direction="left">
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 z-[60] bg-black/10 backdrop-blur-sm" />
|
||||
<Drawer.Content className="fixed left-0 top-0 z-[70] flex h-full w-80 flex-col border-r border-zinc-200 bg-white">
|
||||
<div className="shrink-0 border-b border-zinc-200 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Drawer.Title className="text-lg font-semibold text-zinc-800">
|
||||
Your tasks
|
||||
</Drawer.Title>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="icon"
|
||||
aria-label="Close sessions"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X width="1.25rem" height="1.25rem" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col overflow-y-auto px-3 py-3",
|
||||
scrollbarStyles,
|
||||
)}
|
||||
>
|
||||
<SessionsList
|
||||
sessions={sessions}
|
||||
currentSessionId={currentSessionId}
|
||||
isLoading={isLoading}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
onSelectSession={onSelectSession}
|
||||
onFetchNextPage={onFetchNextPage}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 bg-white p-3 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.05)]">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="small"
|
||||
onClick={onNewChat}
|
||||
className="w-full"
|
||||
leftIcon={<Plus width="1rem" height="1rem" />}
|
||||
>
|
||||
New Chat
|
||||
</Button>
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export function useMobileDrawer() {
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
|
||||
function handleOpenDrawer() {
|
||||
setIsDrawerOpen(true);
|
||||
}
|
||||
|
||||
function handleCloseDrawer() {
|
||||
setIsDrawerOpen(false);
|
||||
}
|
||||
|
||||
function handleDrawerOpenChange(open: boolean) {
|
||||
setIsDrawerOpen(open);
|
||||
}
|
||||
|
||||
return {
|
||||
isDrawerOpen,
|
||||
handleOpenDrawer,
|
||||
handleCloseDrawer,
|
||||
handleDrawerOpenChange,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { List } from "@phosphor-icons/react";
|
||||
|
||||
interface Props {
|
||||
onOpenDrawer: () => void;
|
||||
}
|
||||
|
||||
export function MobileHeader({ onOpenDrawer }: Props) {
|
||||
return (
|
||||
<header className="flex items-center justify-between px-4 py-3">
|
||||
<Button
|
||||
variant="icon"
|
||||
size="icon"
|
||||
aria-label="Open sessions"
|
||||
onClick={onOpenDrawer}
|
||||
>
|
||||
<List width="1.25rem" height="1.25rem" />
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
|
||||
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { InfiniteList } from "@/components/molecules/InfiniteList/InfiniteList";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getSessionTitle } from "../../helpers";
|
||||
|
||||
interface Props {
|
||||
sessions: SessionSummaryResponse[];
|
||||
currentSessionId: string | null;
|
||||
isLoading: boolean;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onFetchNextPage: () => void;
|
||||
}
|
||||
|
||||
export function SessionsList({
|
||||
sessions,
|
||||
currentSessionId,
|
||||
isLoading,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onSelectSession,
|
||||
onFetchNextPage,
|
||||
}: Props) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg px-3 py-2.5">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
No sessions found
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InfiniteList
|
||||
items={sessions}
|
||||
hasMore={hasNextPage}
|
||||
isFetchingMore={isFetchingNextPage}
|
||||
onEndReached={onFetchNextPage}
|
||||
className="space-y-1"
|
||||
renderItem={(session) => {
|
||||
const isActive = session.id === currentSessionId;
|
||||
return (
|
||||
<button
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
className={cn(
|
||||
"w-full rounded-lg px-3 py-2.5 text-left transition-colors",
|
||||
isActive ? "bg-zinc-100" : "hover:bg-zinc-50",
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
className={cn(
|
||||
"font-normal",
|
||||
isActive ? "text-zinc-600" : "text-zinc-800",
|
||||
)}
|
||||
>
|
||||
{getSessionTitle(session)}
|
||||
</Text>
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useGetV2ListSessions } from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
export interface UseSessionsPaginationArgs {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function useSessionsPagination({ enabled }: UseSessionsPaginationArgs) {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [accumulatedSessions, setAccumulatedSessions] = useState<
|
||||
SessionSummaryResponse[]
|
||||
>([]);
|
||||
const [totalCount, setTotalCount] = useState<number | null>(null);
|
||||
|
||||
const { data, isLoading, isFetching, isError } = useGetV2ListSessions(
|
||||
{ limit: PAGE_SIZE, offset },
|
||||
{
|
||||
query: {
|
||||
enabled: enabled && offset >= 0,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const responseData = okData(data);
|
||||
if (responseData) {
|
||||
const newSessions = responseData.sessions;
|
||||
const total = responseData.total;
|
||||
setTotalCount(total);
|
||||
|
||||
if (offset === 0) {
|
||||
setAccumulatedSessions(newSessions);
|
||||
} else {
|
||||
setAccumulatedSessions((prev) => [...prev, ...newSessions]);
|
||||
}
|
||||
}
|
||||
}, [data, offset]);
|
||||
|
||||
const hasNextPage = useMemo(() => {
|
||||
if (totalCount === null) return false;
|
||||
return accumulatedSessions.length < totalCount;
|
||||
}, [accumulatedSessions.length, totalCount]);
|
||||
|
||||
const areAllSessionsLoaded = useMemo(() => {
|
||||
if (totalCount === null) return false;
|
||||
return (
|
||||
accumulatedSessions.length >= totalCount && !isFetching && !isLoading
|
||||
);
|
||||
}, [accumulatedSessions.length, totalCount, isFetching, isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasNextPage &&
|
||||
!isFetching &&
|
||||
!isLoading &&
|
||||
!isError &&
|
||||
totalCount !== null
|
||||
) {
|
||||
setOffset((prev) => prev + PAGE_SIZE);
|
||||
}
|
||||
}, [hasNextPage, isFetching, isLoading, isError, totalCount]);
|
||||
|
||||
function fetchNextPage() {
|
||||
if (hasNextPage && !isFetching) {
|
||||
setOffset((prev) => prev + PAGE_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setOffset(0);
|
||||
setAccumulatedSessions([]);
|
||||
setTotalCount(null);
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: accumulatedSessions,
|
||||
isLoading,
|
||||
isFetching,
|
||||
hasNextPage,
|
||||
areAllSessionsLoaded,
|
||||
totalCount,
|
||||
fetchNextPage,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
||||
import type { SessionSummaryResponse } from "@/app/api/__generated__/models/sessionSummaryResponse";
|
||||
import { format, formatDistanceToNow, isToday } from "date-fns";
|
||||
|
||||
export function convertSessionDetailToSummary(
|
||||
session: SessionDetailResponse,
|
||||
): SessionSummaryResponse {
|
||||
return {
|
||||
id: session.id,
|
||||
created_at: session.created_at,
|
||||
updated_at: session.updated_at,
|
||||
title: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function filterVisibleSessions(
|
||||
sessions: SessionSummaryResponse[],
|
||||
): SessionSummaryResponse[] {
|
||||
return sessions.filter(
|
||||
(session) => session.updated_at !== session.created_at,
|
||||
);
|
||||
}
|
||||
|
||||
export function getSessionTitle(session: SessionSummaryResponse): string {
|
||||
if (session.title) return session.title;
|
||||
const isNewSession = session.updated_at === session.created_at;
|
||||
if (isNewSession) {
|
||||
const createdDate = new Date(session.created_at);
|
||||
if (isToday(createdDate)) {
|
||||
return "Today";
|
||||
}
|
||||
return format(createdDate, "MMM d, yyyy");
|
||||
}
|
||||
return "Untitled Chat";
|
||||
}
|
||||
|
||||
export function getSessionUpdatedLabel(
|
||||
session: SessionSummaryResponse,
|
||||
): string {
|
||||
if (!session.updated_at) return "";
|
||||
return formatDistanceToNow(new Date(session.updated_at), { addSuffix: true });
|
||||
}
|
||||
|
||||
export function mergeCurrentSessionIntoList(
|
||||
accumulatedSessions: SessionSummaryResponse[],
|
||||
currentSessionId: string | null,
|
||||
currentSessionData: SessionDetailResponse | undefined,
|
||||
): SessionSummaryResponse[] {
|
||||
const filteredSessions: SessionSummaryResponse[] = [];
|
||||
|
||||
if (accumulatedSessions.length > 0) {
|
||||
const visibleSessions = filterVisibleSessions(accumulatedSessions);
|
||||
|
||||
if (currentSessionId) {
|
||||
const currentInAll = accumulatedSessions.find(
|
||||
(s) => s.id === currentSessionId,
|
||||
);
|
||||
if (currentInAll) {
|
||||
const isInVisible = visibleSessions.some(
|
||||
(s) => s.id === currentSessionId,
|
||||
);
|
||||
if (!isInVisible) {
|
||||
filteredSessions.push(currentInAll);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
filteredSessions.push(...visibleSessions);
|
||||
}
|
||||
|
||||
if (currentSessionId && currentSessionData) {
|
||||
const isCurrentInList = filteredSessions.some(
|
||||
(s) => s.id === currentSessionId,
|
||||
);
|
||||
if (!isCurrentInList) {
|
||||
const summarySession = convertSessionDetailToSummary(currentSessionData);
|
||||
filteredSessions.unshift(summarySession);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredSessions;
|
||||
}
|
||||
|
||||
export function getCurrentSessionId(
|
||||
searchParams: URLSearchParams,
|
||||
storedSessionId: string | null,
|
||||
): string | null {
|
||||
const paramSessionId = searchParams.get("sessionId");
|
||||
if (paramSessionId) return paramSessionId;
|
||||
if (storedSessionId) return storedSessionId;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function shouldAutoSelectSession(
|
||||
areAllSessionsLoaded: boolean,
|
||||
hasAutoSelectedSession: boolean,
|
||||
paramSessionId: string | null,
|
||||
visibleSessions: SessionSummaryResponse[],
|
||||
accumulatedSessions: SessionSummaryResponse[],
|
||||
isLoading: boolean,
|
||||
totalCount: number | null,
|
||||
): {
|
||||
shouldSelect: boolean;
|
||||
sessionIdToSelect: string | null;
|
||||
shouldCreate: boolean;
|
||||
} {
|
||||
if (!areAllSessionsLoaded || hasAutoSelectedSession) {
|
||||
return {
|
||||
shouldSelect: false,
|
||||
sessionIdToSelect: null,
|
||||
shouldCreate: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (paramSessionId) {
|
||||
return {
|
||||
shouldSelect: false,
|
||||
sessionIdToSelect: null,
|
||||
shouldCreate: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (visibleSessions.length > 0) {
|
||||
return {
|
||||
shouldSelect: true,
|
||||
sessionIdToSelect: visibleSessions[0].id,
|
||||
shouldCreate: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (accumulatedSessions.length === 0 && !isLoading && totalCount === 0) {
|
||||
return { shouldSelect: false, sessionIdToSelect: null, shouldCreate: true };
|
||||
}
|
||||
|
||||
if (totalCount === 0) {
|
||||
return {
|
||||
shouldSelect: false,
|
||||
sessionIdToSelect: null,
|
||||
shouldCreate: false,
|
||||
};
|
||||
}
|
||||
|
||||
return { shouldSelect: false, sessionIdToSelect: null, shouldCreate: false };
|
||||
}
|
||||
|
||||
export function checkReadyToShowContent(
|
||||
areAllSessionsLoaded: boolean,
|
||||
paramSessionId: string | null,
|
||||
accumulatedSessions: SessionSummaryResponse[],
|
||||
isCurrentSessionLoading: boolean,
|
||||
currentSessionData: SessionDetailResponse | undefined,
|
||||
hasAutoSelectedSession: boolean,
|
||||
): boolean {
|
||||
if (!areAllSessionsLoaded) return false;
|
||||
|
||||
if (paramSessionId) {
|
||||
const sessionFound = accumulatedSessions.some(
|
||||
(s) => s.id === paramSessionId,
|
||||
);
|
||||
return (
|
||||
sessionFound ||
|
||||
(!isCurrentSessionLoading && currentSessionData !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
return hasAutoSelectedSession;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
postV2CreateSession,
|
||||
useGetV2GetSession,
|
||||
} from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||
import { Key, storage } from "@/services/storage/local-storage";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMobileDrawer } from "./components/MobileDrawer/useMobileDrawer";
|
||||
import { useSessionsPagination } from "./components/SessionsList/useSessionsPagination";
|
||||
import {
|
||||
checkReadyToShowContent,
|
||||
filterVisibleSessions,
|
||||
getCurrentSessionId,
|
||||
mergeCurrentSessionIntoList,
|
||||
shouldAutoSelectSession,
|
||||
} from "./helpers";
|
||||
|
||||
export function useCopilotShell() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const breakpoint = useBreakpoint();
|
||||
const isMobile =
|
||||
breakpoint === "base" || breakpoint === "sm" || breakpoint === "md";
|
||||
|
||||
const {
|
||||
isDrawerOpen,
|
||||
handleOpenDrawer,
|
||||
handleCloseDrawer,
|
||||
handleDrawerOpenChange,
|
||||
} = useMobileDrawer();
|
||||
|
||||
const paginationEnabled = !isMobile || isDrawerOpen;
|
||||
|
||||
const {
|
||||
sessions: accumulatedSessions,
|
||||
isLoading: isSessionsLoading,
|
||||
isFetching: isSessionsFetching,
|
||||
hasNextPage,
|
||||
areAllSessionsLoaded,
|
||||
totalCount,
|
||||
fetchNextPage,
|
||||
reset: resetPagination,
|
||||
} = useSessionsPagination({
|
||||
enabled: paginationEnabled,
|
||||
});
|
||||
|
||||
const storedSessionId = storage.get(Key.CHAT_SESSION_ID) ?? null;
|
||||
const currentSessionId = useMemo(
|
||||
() => getCurrentSessionId(searchParams, storedSessionId),
|
||||
[searchParams, storedSessionId],
|
||||
);
|
||||
|
||||
const { data: currentSessionData, isLoading: isCurrentSessionLoading } =
|
||||
useGetV2GetSession(currentSessionId || "", {
|
||||
query: {
|
||||
enabled: !!currentSessionId && paginationEnabled,
|
||||
select: okData,
|
||||
},
|
||||
});
|
||||
|
||||
const [hasAutoSelectedSession, setHasAutoSelectedSession] = useState(false);
|
||||
const hasCreatedSessionRef = useRef(false);
|
||||
const paramSessionId = searchParams.get("sessionId");
|
||||
|
||||
const createSessionAndNavigate = useCallback(
|
||||
function createSessionAndNavigate() {
|
||||
postV2CreateSession({ body: JSON.stringify({}) })
|
||||
.then((response) => {
|
||||
if (response.status === 200 && response.data) {
|
||||
router.push(`/copilot/chat?sessionId=${response.data.id}`);
|
||||
setHasAutoSelectedSession(true);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
hasCreatedSessionRef.current = false;
|
||||
});
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!areAllSessionsLoaded || hasAutoSelectedSession) return;
|
||||
|
||||
const visibleSessions = filterVisibleSessions(accumulatedSessions);
|
||||
const autoSelect = shouldAutoSelectSession(
|
||||
areAllSessionsLoaded,
|
||||
hasAutoSelectedSession,
|
||||
paramSessionId,
|
||||
visibleSessions,
|
||||
accumulatedSessions,
|
||||
isSessionsLoading,
|
||||
totalCount,
|
||||
);
|
||||
|
||||
if (paramSessionId) {
|
||||
setHasAutoSelectedSession(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoSelect.shouldSelect && autoSelect.sessionIdToSelect) {
|
||||
setHasAutoSelectedSession(true);
|
||||
router.push(`/copilot/chat?sessionId=${autoSelect.sessionIdToSelect}`);
|
||||
} else if (autoSelect.shouldCreate && !hasCreatedSessionRef.current) {
|
||||
hasCreatedSessionRef.current = true;
|
||||
createSessionAndNavigate();
|
||||
} else if (totalCount === 0) {
|
||||
setHasAutoSelectedSession(true);
|
||||
}
|
||||
}, [
|
||||
areAllSessionsLoaded,
|
||||
accumulatedSessions,
|
||||
paramSessionId,
|
||||
hasAutoSelectedSession,
|
||||
router,
|
||||
isSessionsLoading,
|
||||
totalCount,
|
||||
createSessionAndNavigate,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (paramSessionId) {
|
||||
setHasAutoSelectedSession(true);
|
||||
}
|
||||
}, [paramSessionId]);
|
||||
|
||||
function resetAutoSelect() {
|
||||
setHasAutoSelectedSession(false);
|
||||
hasCreatedSessionRef.current = false;
|
||||
}
|
||||
|
||||
// Reset pagination and auto-selection when query becomes disabled
|
||||
useEffect(() => {
|
||||
if (!paginationEnabled) {
|
||||
resetPagination();
|
||||
resetAutoSelect();
|
||||
}
|
||||
}, [paginationEnabled, resetPagination]);
|
||||
|
||||
const sessions = useMemo(
|
||||
function getSessions() {
|
||||
return mergeCurrentSessionIntoList(
|
||||
accumulatedSessions,
|
||||
currentSessionId,
|
||||
currentSessionData,
|
||||
);
|
||||
},
|
||||
[accumulatedSessions, currentSessionId, currentSessionData],
|
||||
);
|
||||
|
||||
function handleSelectSession(sessionId: string) {
|
||||
router.push(`/copilot/chat?sessionId=${sessionId}`);
|
||||
if (isMobile) handleCloseDrawer();
|
||||
}
|
||||
|
||||
function handleNewChat() {
|
||||
storage.clean(Key.CHAT_SESSION_ID);
|
||||
resetAutoSelect();
|
||||
createSessionAndNavigate();
|
||||
if (isMobile) handleCloseDrawer();
|
||||
}
|
||||
|
||||
const isReadyToShowContent = useMemo(
|
||||
() =>
|
||||
checkReadyToShowContent(
|
||||
areAllSessionsLoaded,
|
||||
paramSessionId,
|
||||
accumulatedSessions,
|
||||
isCurrentSessionLoading,
|
||||
currentSessionData,
|
||||
hasAutoSelectedSession,
|
||||
),
|
||||
[
|
||||
areAllSessionsLoaded,
|
||||
paramSessionId,
|
||||
accumulatedSessions,
|
||||
isCurrentSessionLoading,
|
||||
currentSessionData,
|
||||
hasAutoSelectedSession,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
isDrawerOpen,
|
||||
isLoading: isSessionsLoading || !areAllSessionsLoaded,
|
||||
sessions,
|
||||
currentSessionId,
|
||||
handleSelectSession,
|
||||
handleOpenDrawer,
|
||||
handleCloseDrawer,
|
||||
handleDrawerOpenChange,
|
||||
handleNewChat,
|
||||
hasNextPage,
|
||||
isFetchingNextPage: isSessionsFetching,
|
||||
fetchNextPage,
|
||||
isReadyToShowContent,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
|
||||
export function getGreetingName(user?: User | null): string {
|
||||
if (!user) return "there";
|
||||
const metadata = user.user_metadata as Record<string, unknown> | undefined;
|
||||
const fullName = metadata?.full_name;
|
||||
const name = metadata?.name;
|
||||
if (typeof fullName === "string" && fullName.trim()) {
|
||||
return fullName.split(" ")[0];
|
||||
}
|
||||
if (typeof name === "string" && name.trim()) {
|
||||
return name.split(" ")[0];
|
||||
}
|
||||
if (user.email) {
|
||||
return user.email.split("@")[0];
|
||||
}
|
||||
return "there";
|
||||
}
|
||||
|
||||
export function buildCopilotChatUrl(prompt: string): string {
|
||||
const trimmed = prompt.trim();
|
||||
if (!trimmed) return "/copilot/chat";
|
||||
const encoded = encodeURIComponent(trimmed);
|
||||
return `/copilot/chat?prompt=${encoded}`;
|
||||
}
|
||||
|
||||
export function getQuickActions(): string[] {
|
||||
return [
|
||||
"Show me what I can automate",
|
||||
"Design a custom workflow",
|
||||
"Help me with content creation",
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { CopilotShell } from "./components/CopilotShell/CopilotShell";
|
||||
|
||||
export default function CopilotLayout({ children }: { children: ReactNode }) {
|
||||
return <CopilotShell>{children}</CopilotShell>;
|
||||
}
|
||||
102
autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx
Normal file
102
autogpt_platform/frontend/src/app/(platform)/copilot/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Input } from "@/components/atoms/Input/Input";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { ArrowUpIcon } from "@phosphor-icons/react";
|
||||
import { useCopilotHome } from "./useCopilotHome";
|
||||
|
||||
export default function CopilotPage() {
|
||||
const {
|
||||
greetingName,
|
||||
value,
|
||||
quickActions,
|
||||
isFlagReady,
|
||||
isChatEnabled,
|
||||
handleChange,
|
||||
handleSubmit,
|
||||
handleKeyDown,
|
||||
handleQuickAction,
|
||||
} = useCopilotHome();
|
||||
const { isUserLoading } = useSupabase();
|
||||
|
||||
if (!isFlagReady || isChatEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isLoading = isUserLoading;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-1 items-center justify-center overflow-y-auto px-6 py-10">
|
||||
<div className="w-full max-w-2xl text-center">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Skeleton className="mx-auto mb-3 h-8 w-64" />
|
||||
<Skeleton className="mx-auto mb-8 h-6 w-80" />
|
||||
<div className="mb-8">
|
||||
<Skeleton className="mx-auto h-14 w-full max-w-2xl rounded-lg" />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-9 w-48 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text variant="h2" className="mb-3 text-zinc-700">
|
||||
Hey, <span className="text-violet-600">{greetingName}</span>
|
||||
</Text>
|
||||
<Text variant="h3" className="mb-8 text-zinc-900">
|
||||
What do you want to automate?
|
||||
</Text>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mb-8">
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="copilot-prompt"
|
||||
label="Copilot prompt"
|
||||
hideLabel
|
||||
type="textarea"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={1}
|
||||
placeholder='You can search or just ask - e.g. "create a blog post outline"'
|
||||
wrapperClassName="mb-0"
|
||||
className="min-h-[3.5rem] pr-12 text-base"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="icon"
|
||||
size="icon"
|
||||
aria-label="Submit prompt"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 border-zinc-800 bg-zinc-800 text-white hover:border-zinc-900 hover:bg-zinc-900"
|
||||
disabled={!value.trim()}
|
||||
>
|
||||
<ArrowUpIcon className="h-4 w-4" weight="bold" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
{quickActions.map((action) => (
|
||||
<Button
|
||||
key={action}
|
||||
variant="outline"
|
||||
size="small"
|
||||
onClick={() => handleQuickAction(action)}
|
||||
className="border-zinc-300 text-zinc-700 hover:border-zinc-400 hover:bg-zinc-50"
|
||||
>
|
||||
{action}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { getHomepageRoute } from "@/lib/constants";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import {
|
||||
Flag,
|
||||
type FlagValues,
|
||||
useGetFlag,
|
||||
} from "@/services/feature-flags/use-get-flag";
|
||||
import { useFlags } from "launchdarkly-react-client-sdk";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
buildCopilotChatUrl,
|
||||
getGreetingName,
|
||||
getQuickActions,
|
||||
} from "./helpers";
|
||||
|
||||
export function useCopilotHome() {
|
||||
const router = useRouter();
|
||||
const { user } = useSupabase();
|
||||
const [value, setValue] = useState("");
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
const flags = useFlags<FlagValues>();
|
||||
const homepageRoute = getHomepageRoute(isChatEnabled);
|
||||
const envEnabled = process.env.NEXT_PUBLIC_LAUNCHDARKLY_ENABLED === "true";
|
||||
const clientId = process.env.NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID;
|
||||
const isLaunchDarklyConfigured = envEnabled && Boolean(clientId);
|
||||
const isFlagReady =
|
||||
!isLaunchDarklyConfigured || flags[Flag.CHAT] !== undefined;
|
||||
|
||||
const greetingName = useMemo(
|
||||
function getName() {
|
||||
return getGreetingName(user);
|
||||
},
|
||||
[user],
|
||||
);
|
||||
|
||||
const quickActions = useMemo(function getActions() {
|
||||
return getQuickActions();
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
function ensureAccess() {
|
||||
if (!isFlagReady) return;
|
||||
if (isChatEnabled === false) {
|
||||
router.replace(homepageRoute);
|
||||
}
|
||||
},
|
||||
[homepageRoute, isChatEnabled, isFlagReady, router],
|
||||
);
|
||||
|
||||
function handleChange(
|
||||
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) {
|
||||
setValue(event.target.value);
|
||||
}
|
||||
|
||||
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!value.trim()) return;
|
||||
router.push(buildCopilotChatUrl(value));
|
||||
}
|
||||
|
||||
function handleKeyDown(
|
||||
event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) {
|
||||
if (event.key !== "Enter") return;
|
||||
if (event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
if (!value.trim()) return;
|
||||
router.push(buildCopilotChatUrl(value));
|
||||
}
|
||||
|
||||
function handleQuickAction(action: string) {
|
||||
router.push(buildCopilotChatUrl(action));
|
||||
}
|
||||
|
||||
return {
|
||||
greetingName,
|
||||
value,
|
||||
quickActions,
|
||||
isFlagReady,
|
||||
isChatEnabled,
|
||||
handleChange,
|
||||
handleSubmit,
|
||||
handleKeyDown,
|
||||
handleQuickAction,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { getHomepageRoute } from "@/lib/constants";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { getErrorDetails } from "./helpers";
|
||||
@@ -9,6 +11,8 @@ function ErrorPageContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const errorMessage = searchParams.get("message");
|
||||
const errorDetails = getErrorDetails(errorMessage);
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
const homepageRoute = getHomepageRoute(isChatEnabled);
|
||||
|
||||
function handleRetry() {
|
||||
// Auth-related errors should redirect to login
|
||||
@@ -25,8 +29,8 @@ function ErrorPageContent() {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
// For server/network errors, go to marketplace
|
||||
window.location.href = "/marketplace";
|
||||
// For server/network errors, go to home
|
||||
window.location.href = homepageRoute;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@ export function RunAgentModal({
|
||||
|
||||
{/* Content */}
|
||||
{hasAnySetupFields ? (
|
||||
<div className="mt-10 pb-32">
|
||||
<div className="mt-4 pb-10">
|
||||
<RunAgentModalContextProvider
|
||||
value={{
|
||||
agent,
|
||||
|
||||
@@ -29,7 +29,7 @@ export function ModalHeader({ agent }: ModalHeaderProps) {
|
||||
<ShowMoreText
|
||||
previewLimit={400}
|
||||
variant="small"
|
||||
className="mt-4 !text-zinc-700"
|
||||
className="mb-2 mt-4 !text-zinc-700"
|
||||
>
|
||||
{agent.description}
|
||||
</ShowMoreText>
|
||||
@@ -40,6 +40,8 @@ export function ModalHeader({ agent }: ModalHeaderProps) {
|
||||
<Text variant="lead-semibold" className="text-blue-600">
|
||||
Tip
|
||||
</Text>
|
||||
<div className="h-px w-full bg-blue-100" />
|
||||
|
||||
<Text variant="body">
|
||||
For best results, run this agent{" "}
|
||||
{humanizeCronExpression(
|
||||
@@ -50,7 +52,7 @@ export function ModalHeader({ agent }: ModalHeaderProps) {
|
||||
) : null}
|
||||
|
||||
{agent.instructions ? (
|
||||
<div className="flex flex-col gap-4 rounded-medium border border-purple-100 bg-[#F1EBFE/5] p-4">
|
||||
<div className="mt-4 flex flex-col gap-4 rounded-medium border border-purple-100 bg-[#f1ebfe80] p-4">
|
||||
<Text variant="lead-semibold" className="text-purple-600">
|
||||
Instructions
|
||||
</Text>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/
|
||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { isLogoutInProgress } from "@/lib/autogpt-server-api/helpers";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { updateFavoriteInQueries } from "./helpers";
|
||||
|
||||
interface Props {
|
||||
@@ -23,10 +25,14 @@ export function useLibraryAgentCard({ agent }: Props) {
|
||||
const { toast } = useToast();
|
||||
const queryClient = getQueryClient();
|
||||
const { mutateAsync: updateLibraryAgent } = usePatchV2UpdateLibraryAgent();
|
||||
const { user, isLoggedIn } = useSupabase();
|
||||
const logoutInProgress = isLogoutInProgress();
|
||||
|
||||
const { data: profile } = useGetV2GetUserProfile({
|
||||
query: {
|
||||
select: okData,
|
||||
enabled: isLoggedIn && !!user && !logoutInProgress,
|
||||
queryKey: ["/api/store/profile", user?.id],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { getHomepageRoute } from "@/lib/constants";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { environment } from "@/services/environment";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { loginFormSchema, LoginProvider } from "@/types/auth";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -20,15 +22,17 @@ export function useLoginPage() {
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
const [showNotAllowedModal, setShowNotAllowedModal] = useState(false);
|
||||
const isCloudEnv = environment.isCloud();
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
const homepageRoute = getHomepageRoute(isChatEnabled);
|
||||
|
||||
// Get redirect destination from 'next' query parameter
|
||||
const nextUrl = searchParams.get("next");
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn && !isLoggingIn) {
|
||||
router.push(nextUrl || "/marketplace");
|
||||
router.push(nextUrl || homepageRoute);
|
||||
}
|
||||
}, [isLoggedIn, isLoggingIn, nextUrl, router]);
|
||||
}, [homepageRoute, isLoggedIn, isLoggingIn, nextUrl, router]);
|
||||
|
||||
const form = useForm<z.infer<typeof loginFormSchema>>({
|
||||
resolver: zodResolver(loginFormSchema),
|
||||
@@ -98,7 +102,7 @@ export function useLoginPage() {
|
||||
} else if (result.onboarding) {
|
||||
router.replace("/onboarding");
|
||||
} else {
|
||||
router.replace("/marketplace");
|
||||
router.replace(homepageRoute);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { ProfileInfoForm } from "@/components/__legacy__/ProfileInfoForm";
|
||||
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
|
||||
import { isLogoutInProgress } from "@/lib/autogpt-server-api/helpers";
|
||||
import { ProfileDetails } from "@/lib/autogpt-server-api/types";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { ProfileLoading } from "./ProfileLoading";
|
||||
|
||||
export default function UserProfilePage() {
|
||||
const { user } = useSupabase();
|
||||
const logoutInProgress = isLogoutInProgress();
|
||||
|
||||
const {
|
||||
data: profile,
|
||||
@@ -18,7 +20,7 @@ export default function UserProfilePage() {
|
||||
refetch,
|
||||
} = useGetV2GetUserProfile<ProfileDetails | null>({
|
||||
query: {
|
||||
enabled: !!user,
|
||||
enabled: !!user && !logoutInProgress,
|
||||
select: (res) => {
|
||||
if (res.status === 200) {
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { getHomepageRoute } from "@/lib/constants";
|
||||
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
|
||||
import { signupFormSchema } from "@/types/auth";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
@@ -58,7 +59,7 @@ export async function signup(
|
||||
}
|
||||
|
||||
const isOnboardingEnabled = await shouldShowOnboarding();
|
||||
const next = isOnboardingEnabled ? "/onboarding" : "/";
|
||||
const next = isOnboardingEnabled ? "/onboarding" : getHomepageRoute();
|
||||
|
||||
return { success: true, next };
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { getHomepageRoute } from "@/lib/constants";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { environment } from "@/services/environment";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import { LoginProvider, signupFormSchema } from "@/types/auth";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
@@ -20,15 +22,17 @@ export function useSignupPage() {
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false);
|
||||
const [showNotAllowedModal, setShowNotAllowedModal] = useState(false);
|
||||
const isCloudEnv = environment.isCloud();
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
const homepageRoute = getHomepageRoute(isChatEnabled);
|
||||
|
||||
// Get redirect destination from 'next' query parameter
|
||||
const nextUrl = searchParams.get("next");
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn && !isSigningUp) {
|
||||
router.push(nextUrl || "/marketplace");
|
||||
router.push(nextUrl || homepageRoute);
|
||||
}
|
||||
}, [isLoggedIn, isSigningUp, nextUrl, router]);
|
||||
}, [homepageRoute, isLoggedIn, isSigningUp, nextUrl, router]);
|
||||
|
||||
const form = useForm<z.infer<typeof signupFormSchema>>({
|
||||
resolver: zodResolver(signupFormSchema),
|
||||
@@ -129,7 +133,7 @@ export function useSignupPage() {
|
||||
}
|
||||
|
||||
// Prefer the URL's next parameter, then result.next (for onboarding), then default
|
||||
const redirectTo = nextUrl || result.next || "/";
|
||||
const redirectTo = nextUrl || result.next || homepageRoute;
|
||||
router.replace(redirectTo);
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
import { redirect } from "next/navigation";
|
||||
"use client";
|
||||
|
||||
import { getHomepageRoute } from "@/lib/constants";
|
||||
import {
|
||||
Flag,
|
||||
type FlagValues,
|
||||
useGetFlag,
|
||||
} from "@/services/feature-flags/use-get-flag";
|
||||
import { useFlags } from "launchdarkly-react-client-sdk";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function Page() {
|
||||
redirect("/marketplace");
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
const flags = useFlags<FlagValues>();
|
||||
const router = useRouter();
|
||||
const homepageRoute = getHomepageRoute(isChatEnabled);
|
||||
const envEnabled = process.env.NEXT_PUBLIC_LAUNCHDARKLY_ENABLED === "true";
|
||||
const clientId = process.env.NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID;
|
||||
const isLaunchDarklyConfigured = envEnabled && Boolean(clientId);
|
||||
const isFlagReady =
|
||||
!isLaunchDarklyConfigured || flags[Flag.CHAT] !== undefined;
|
||||
|
||||
useEffect(
|
||||
function redirectToHomepage() {
|
||||
if (!isFlagReady) return;
|
||||
router.replace(homepageRoute);
|
||||
},
|
||||
[homepageRoute, isFlagReady, router],
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,32 +3,32 @@
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { List } from "@phosphor-icons/react";
|
||||
import React, { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { ChatContainer } from "./components/ChatContainer/ChatContainer";
|
||||
import { ChatErrorState } from "./components/ChatErrorState/ChatErrorState";
|
||||
import { ChatLoadingState } from "./components/ChatLoadingState/ChatLoadingState";
|
||||
import { SessionsDrawer } from "./components/SessionsDrawer/SessionsDrawer";
|
||||
import { ChatLoader } from "./components/ChatLoader/ChatLoader";
|
||||
import { useChat } from "./useChat";
|
||||
|
||||
export interface ChatProps {
|
||||
className?: string;
|
||||
headerTitle?: React.ReactNode;
|
||||
showHeader?: boolean;
|
||||
showSessionInfo?: boolean;
|
||||
showNewChatButton?: boolean;
|
||||
onNewChat?: () => void;
|
||||
headerActions?: React.ReactNode;
|
||||
headerActions?: ReactNode;
|
||||
urlSessionId?: string | null;
|
||||
initialPrompt?: string | null;
|
||||
}
|
||||
|
||||
export function Chat({
|
||||
className,
|
||||
headerTitle = "AutoGPT Copilot",
|
||||
showHeader = true,
|
||||
showSessionInfo = true,
|
||||
showNewChatButton = true,
|
||||
onNewChat,
|
||||
headerActions,
|
||||
urlSessionId,
|
||||
initialPrompt,
|
||||
}: ChatProps) {
|
||||
const {
|
||||
messages,
|
||||
@@ -38,46 +38,20 @@ export function Chat({
|
||||
sessionId,
|
||||
createSession,
|
||||
clearSession,
|
||||
loadSession,
|
||||
} = useChat();
|
||||
showLoader,
|
||||
} = useChat({ urlSessionId });
|
||||
|
||||
const [isSessionsDrawerOpen, setIsSessionsDrawerOpen] = useState(false);
|
||||
|
||||
const handleNewChat = () => {
|
||||
function handleNewChat() {
|
||||
clearSession();
|
||||
onNewChat?.();
|
||||
};
|
||||
|
||||
const handleSelectSession = async (sessionId: string) => {
|
||||
try {
|
||||
await loadSession(sessionId);
|
||||
} catch (err) {
|
||||
console.error("Failed to load session:", err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)}>
|
||||
{/* Header */}
|
||||
{showHeader && (
|
||||
<header className="shrink-0 border-t border-zinc-200 bg-white p-3">
|
||||
<header className="shrink-0 bg-[#f8f8f9] p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
aria-label="View sessions"
|
||||
onClick={() => setIsSessionsDrawerOpen(true)}
|
||||
className="flex size-8 items-center justify-center rounded hover:bg-zinc-100"
|
||||
>
|
||||
<List width="1.25rem" height="1.25rem" />
|
||||
</button>
|
||||
{typeof headerTitle === "string" ? (
|
||||
<Text variant="h2" className="text-lg font-semibold">
|
||||
{headerTitle}
|
||||
</Text>
|
||||
) : (
|
||||
headerTitle
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{showSessionInfo && sessionId && (
|
||||
<>
|
||||
@@ -99,12 +73,17 @@ export function Chat({
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Loading State - show when explicitly loading/creating OR when we don't have a session yet and no error */}
|
||||
{(isLoading || isCreating || (!sessionId && !error)) && (
|
||||
<ChatLoadingState
|
||||
message={isCreating ? "Creating session..." : "Loading..."}
|
||||
/>
|
||||
<main className="flex min-h-0 w-full flex-1 flex-col overflow-hidden">
|
||||
{/* Loading State - show loader when loading or creating a session (with 300ms delay) */}
|
||||
{showLoader && (isLoading || isCreating) && (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<ChatLoader />
|
||||
<Text variant="body" className="text-zinc-500">
|
||||
Loading your chats...
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
@@ -117,18 +96,11 @@ export function Chat({
|
||||
<ChatContainer
|
||||
sessionId={sessionId}
|
||||
initialMessages={messages}
|
||||
initialPrompt={initialPrompt}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Sessions Drawer */}
|
||||
<SessionsDrawer
|
||||
isOpen={isSessionsDrawerOpen}
|
||||
onClose={() => setIsSessionsDrawerOpen(false)}
|
||||
onSelectSession={handleSelectSession}
|
||||
currentSessionId={sessionId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface AIChatBubbleProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AIChatBubble({ children, className }: AIChatBubbleProps) {
|
||||
return (
|
||||
<div className={cn("text-left text-sm leading-relaxed", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export function AuthPromptWidget({
|
||||
message,
|
||||
sessionId,
|
||||
agentInfo,
|
||||
returnUrl = "/chat",
|
||||
returnUrl = "/copilot/chat",
|
||||
className,
|
||||
}: AuthPromptWidgetProps) {
|
||||
const router = useRouter();
|
||||
@@ -1,22 +1,23 @@
|
||||
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { usePageContext } from "../../usePageContext";
|
||||
import { ChatInput } from "../ChatInput/ChatInput";
|
||||
import { MessageList } from "../MessageList/MessageList";
|
||||
import { QuickActionsWelcome } from "../QuickActionsWelcome/QuickActionsWelcome";
|
||||
import { useChatContainer } from "./useChatContainer";
|
||||
|
||||
export interface ChatContainerProps {
|
||||
sessionId: string | null;
|
||||
initialMessages: SessionDetailResponse["messages"];
|
||||
className?: string;
|
||||
initialPrompt?: string | null;
|
||||
}
|
||||
|
||||
export function ChatContainer({
|
||||
sessionId,
|
||||
initialMessages,
|
||||
className,
|
||||
initialPrompt,
|
||||
}: ChatContainerProps) {
|
||||
const { messages, streamingChunks, isStreaming, sendMessage } =
|
||||
useChatContainer({
|
||||
@@ -24,6 +25,7 @@ export function ChatContainer({
|
||||
initialMessages,
|
||||
});
|
||||
const { capturePageContext } = usePageContext();
|
||||
const hasSentInitialRef = useRef(false);
|
||||
|
||||
// Wrap sendMessage to automatically capture page context
|
||||
const sendMessageWithContext = useCallback(
|
||||
@@ -34,35 +36,28 @@ export function ChatContainer({
|
||||
[sendMessage, capturePageContext],
|
||||
);
|
||||
|
||||
const quickActions = [
|
||||
"Find agents for social media management",
|
||||
"Show me agents for content creation",
|
||||
"Help me automate my business",
|
||||
"What can you help me with?",
|
||||
];
|
||||
useEffect(
|
||||
function handleInitialPrompt() {
|
||||
if (!initialPrompt) return;
|
||||
if (hasSentInitialRef.current) return;
|
||||
if (!sessionId) return;
|
||||
if (messages.length > 0) return;
|
||||
hasSentInitialRef.current = true;
|
||||
void sendMessageWithContext(initialPrompt);
|
||||
},
|
||||
[initialPrompt, messages.length, sendMessageWithContext, sessionId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex h-full min-h-0 flex-col", className)}
|
||||
style={{
|
||||
backgroundColor: "#ffffff",
|
||||
backgroundImage:
|
||||
"radial-gradient(#e5e5e5 0.5px, transparent 0.5px), radial-gradient(#e5e5e5 0.5px, #ffffff 0.5px)",
|
||||
backgroundSize: "20px 20px",
|
||||
backgroundPosition: "0 0, 10px 10px",
|
||||
}}
|
||||
className={cn(
|
||||
"mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col bg-[#f8f8f9]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Messages or Welcome Screen */}
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden pb-24">
|
||||
{messages.length === 0 ? (
|
||||
<QuickActionsWelcome
|
||||
title="Welcome to AutoGPT Copilot"
|
||||
description="Start a conversation to discover and run AI agents."
|
||||
actions={quickActions}
|
||||
onActionClick={sendMessageWithContext}
|
||||
disabled={isStreaming || !sessionId}
|
||||
/>
|
||||
) : (
|
||||
{/* Messages or Welcome Screen - Scrollable */}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
<div className="flex min-h-full flex-col justify-end">
|
||||
<MessageList
|
||||
messages={messages}
|
||||
streamingChunks={streamingChunks}
|
||||
@@ -70,17 +65,16 @@ export function ChatContainer({
|
||||
onSendMessage={sendMessageWithContext}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input - Always visible */}
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 border-t border-zinc-200 bg-white p-4">
|
||||
{/* Input - Fixed at bottom */}
|
||||
<div className="relative pb-4 pt-2">
|
||||
<div className="pointer-events-none absolute top-[-18px] z-10 h-6 w-full bg-gradient-to-b from-transparent to-[#f8f8f9]" />
|
||||
<ChatInput
|
||||
onSend={sendMessageWithContext}
|
||||
disabled={isStreaming || !sessionId}
|
||||
placeholder={
|
||||
sessionId ? "Type your message..." : "Creating session..."
|
||||
}
|
||||
placeholder="You can search or just ask — e.g. “create a blog post outline”"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,13 +33,23 @@ export function handleTextEnded(
|
||||
console.log("[Text Ended] Saving streamed text as assistant message");
|
||||
const completedText = deps.streamingChunksRef.current.join("");
|
||||
if (completedText.trim()) {
|
||||
const assistantMessage: ChatMessageData = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: completedText,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
deps.setMessages((prev) => [...prev, assistantMessage]);
|
||||
deps.setMessages((prev) => {
|
||||
const lastMessage = prev[prev.length - 1];
|
||||
console.log("[Text Ended] Previous message:", {
|
||||
type: lastMessage?.type,
|
||||
toolName:
|
||||
lastMessage?.type === "tool_call" ? lastMessage.toolName : undefined,
|
||||
content: completedText.substring(0, 200),
|
||||
});
|
||||
|
||||
const assistantMessage: ChatMessageData = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: completedText,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
return [...prev, assistantMessage];
|
||||
});
|
||||
}
|
||||
deps.setStreamingChunks([]);
|
||||
deps.streamingChunksRef.current = [];
|
||||
@@ -3,7 +3,7 @@ import { cn } from "@/lib/utils";
|
||||
import { ArrowUpIcon } from "@phosphor-icons/react";
|
||||
import { useChatInput } from "./useChatInput";
|
||||
|
||||
export interface ChatInputProps {
|
||||
export interface Props {
|
||||
onSend: (message: string) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
@@ -15,7 +15,7 @@ export function ChatInput({
|
||||
disabled = false,
|
||||
placeholder = "Type your message...",
|
||||
className,
|
||||
}: ChatInputProps) {
|
||||
}: Props) {
|
||||
const inputId = "chat-input";
|
||||
const { value, setValue, handleKeyDown, handleSend } = useChatInput({
|
||||
onSend,
|
||||
@@ -0,0 +1,13 @@
|
||||
.loader {
|
||||
width: 20px;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: #000;
|
||||
box-shadow: 0 0 0 0 #0004;
|
||||
animation: l1 1s infinite;
|
||||
}
|
||||
@keyframes l1 {
|
||||
100% {
|
||||
box-shadow: 0 0 0 30px #0000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import styles from "./ChatLoader.module.css";
|
||||
|
||||
export function ChatLoader() {
|
||||
return <div className={styles.loader} />;
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
|
||||
import Avatar, {
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/atoms/Avatar/Avatar";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -13,20 +8,19 @@ import {
|
||||
CheckCircleIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
RobotIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
import { getToolActionPhrase } from "../../helpers";
|
||||
import { AgentCarouselMessage } from "../AgentCarouselMessage/AgentCarouselMessage";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
import { AuthPromptWidget } from "../AuthPromptWidget/AuthPromptWidget";
|
||||
import { ChatCredentialsSetup } from "../ChatCredentialsSetup/ChatCredentialsSetup";
|
||||
import { ExecutionStartedMessage } from "../ExecutionStartedMessage/ExecutionStartedMessage";
|
||||
import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
|
||||
import { MessageBubble } from "../MessageBubble/MessageBubble";
|
||||
import { NoResultsMessage } from "../NoResultsMessage/NoResultsMessage";
|
||||
import { ToolCallMessage } from "../ToolCallMessage/ToolCallMessage";
|
||||
import { ToolResponseMessage } from "../ToolResponseMessage/ToolResponseMessage";
|
||||
import { UserChatBubble } from "../UserChatBubble/UserChatBubble";
|
||||
import { useChatMessage, type ChatMessageData } from "./useChatMessage";
|
||||
export interface ChatMessageProps {
|
||||
message: ChatMessageData;
|
||||
@@ -35,6 +29,7 @@ export interface ChatMessageProps {
|
||||
onDismissCredentials?: () => void;
|
||||
onSendMessage?: (content: string, isUserMessage?: boolean) => void;
|
||||
agentOutput?: ChatMessageData;
|
||||
isFinalMessage?: boolean;
|
||||
}
|
||||
|
||||
export function ChatMessage({
|
||||
@@ -43,6 +38,7 @@ export function ChatMessage({
|
||||
onDismissCredentials,
|
||||
onSendMessage,
|
||||
agentOutput,
|
||||
isFinalMessage = true,
|
||||
}: ChatMessageProps) {
|
||||
const { user } = useSupabase();
|
||||
const router = useRouter();
|
||||
@@ -55,14 +51,6 @@ export function ChatMessage({
|
||||
isCredentialsNeeded,
|
||||
} = useChatMessage(message);
|
||||
|
||||
const { data: profile } = useGetV2GetUserProfile({
|
||||
query: {
|
||||
select: (res) => (res.status === 200 ? res.data : null),
|
||||
enabled: isUser && !!user,
|
||||
queryKey: ["/api/store/profile", user?.id],
|
||||
},
|
||||
});
|
||||
|
||||
const handleAllCredentialsComplete = useCallback(
|
||||
function handleAllCredentialsComplete() {
|
||||
// Send a user message that explicitly asks to retry the setup
|
||||
@@ -171,7 +159,11 @@ export function ChatMessage({
|
||||
if (isToolCall && message.type === "tool_call") {
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<ToolCallMessage toolName={message.toolName} />
|
||||
<ToolCallMessage
|
||||
toolId={message.toolId}
|
||||
toolName={message.toolName}
|
||||
arguments={message.arguments}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -218,27 +210,11 @@ export function ChatMessage({
|
||||
|
||||
// Render tool response messages (but skip agent_output if it's being rendered inside assistant message)
|
||||
if (isToolResponse && message.type === "tool_response") {
|
||||
// Check if this is an agent_output that should be rendered inside assistant message
|
||||
if (message.result) {
|
||||
let parsedResult: Record<string, unknown> | null = null;
|
||||
try {
|
||||
parsedResult =
|
||||
typeof message.result === "string"
|
||||
? JSON.parse(message.result)
|
||||
: (message.result as Record<string, unknown>);
|
||||
} catch {
|
||||
parsedResult = null;
|
||||
}
|
||||
if (parsedResult?.type === "agent_output") {
|
||||
// Skip rendering - this will be rendered inside the assistant message
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("px-4 py-2", className)}>
|
||||
<ToolResponseMessage
|
||||
toolName={getToolActionPhrase(message.toolName)}
|
||||
toolId={message.toolId}
|
||||
toolName={message.toolName}
|
||||
result={message.result}
|
||||
/>
|
||||
</div>
|
||||
@@ -256,40 +232,33 @@ export function ChatMessage({
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full max-w-3xl gap-3">
|
||||
{!isUser && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-500">
|
||||
<RobotIcon className="h-4 w-4 text-indigo-50" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col",
|
||||
isUser && "items-end",
|
||||
)}
|
||||
>
|
||||
<MessageBubble variant={isUser ? "user" : "assistant"}>
|
||||
<MarkdownContent content={message.content} />
|
||||
{agentOutput &&
|
||||
agentOutput.type === "tool_response" &&
|
||||
!isUser && (
|
||||
{isUser ? (
|
||||
<UserChatBubble>
|
||||
<MarkdownContent content={message.content} />
|
||||
</UserChatBubble>
|
||||
) : (
|
||||
<AIChatBubble>
|
||||
<MarkdownContent content={message.content} />
|
||||
{agentOutput && agentOutput.type === "tool_response" && (
|
||||
<div className="mt-4">
|
||||
<ToolResponseMessage
|
||||
toolName={
|
||||
agentOutput.toolName
|
||||
? getToolActionPhrase(agentOutput.toolName)
|
||||
: "Agent Output"
|
||||
}
|
||||
toolId={agentOutput.toolId}
|
||||
toolName={agentOutput.toolName || "Agent Output"}
|
||||
result={agentOutput.result}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</MessageBubble>
|
||||
</AIChatBubble>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex gap-1",
|
||||
"flex gap-1",
|
||||
isUser ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
@@ -303,34 +272,22 @@ export function ChatMessage({
|
||||
<ArrowClockwise className="size-3 text-neutral-500" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy message"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-3 text-green-600" />
|
||||
) : (
|
||||
<CopyIcon className="size-3 text-neutral-500" />
|
||||
)}
|
||||
</Button>
|
||||
{(isUser || isFinalMessage) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy message"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-3 text-green-600" />
|
||||
) : (
|
||||
<CopyIcon className="size-3 text-neutral-500" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isUser && (
|
||||
<div className="flex-shrink-0">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarImage
|
||||
src={profile?.avatar_url ?? ""}
|
||||
alt={profile?.username ?? "User"}
|
||||
/>
|
||||
<AvatarFallback className="rounded-lg bg-neutral-200 text-neutral-600">
|
||||
{profile?.username?.charAt(0)?.toUpperCase() || "U"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -13,10 +13,9 @@ export function MessageBubble({
|
||||
className,
|
||||
}: MessageBubbleProps) {
|
||||
const userTheme = {
|
||||
bg: "bg-slate-900",
|
||||
border: "border-slate-800",
|
||||
gradient: "from-slate-900/30 via-slate-800/20 to-transparent",
|
||||
text: "text-slate-50",
|
||||
bg: "bg-purple-100",
|
||||
border: "border-purple-100",
|
||||
text: "text-slate-900",
|
||||
};
|
||||
|
||||
const assistantTheme = {
|
||||
@@ -40,9 +39,7 @@ export function MessageBubble({
|
||||
)}
|
||||
>
|
||||
{/* Gradient flare background */}
|
||||
<div
|
||||
className={cn("absolute inset-0 bg-gradient-to-br", theme.gradient)}
|
||||
/>
|
||||
<div className={cn("absolute inset-0 bg-gradient-to-br")} />
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 transition-all duration-500 ease-in-out",
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||
import { StreamingMessage } from "../StreamingMessage/StreamingMessage";
|
||||
import { ThinkingMessage } from "../ThinkingMessage/ThinkingMessage";
|
||||
import { LastToolResponse } from "./components/LastToolResponse/LastToolResponse";
|
||||
import { MessageItem } from "./components/MessageItem/MessageItem";
|
||||
import { findLastMessageIndex, shouldSkipAgentOutput } from "./helpers";
|
||||
import { useMessageList } from "./useMessageList";
|
||||
|
||||
export interface MessageListProps {
|
||||
messages: ChatMessageData[];
|
||||
streamingChunks?: string[];
|
||||
isStreaming?: boolean;
|
||||
className?: string;
|
||||
onStreamComplete?: () => void;
|
||||
onSendMessage?: (content: string) => void;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
streamingChunks = [],
|
||||
isStreaming = false,
|
||||
className,
|
||||
onStreamComplete,
|
||||
onSendMessage,
|
||||
}: MessageListProps) {
|
||||
const { messagesEndRef, messagesContainerRef } = useMessageList({
|
||||
messageCount: messages.length,
|
||||
isStreaming,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className={cn(
|
||||
"flex-1 overflow-y-auto overflow-x-hidden",
|
||||
"scrollbar-thin scrollbar-track-transparent scrollbar-thumb-zinc-300",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex min-w-0 flex-col hyphens-auto break-words py-4">
|
||||
{/* Render all persisted messages */}
|
||||
{(() => {
|
||||
const lastAssistantMessageIndex = findLastMessageIndex(
|
||||
messages,
|
||||
(msg) => msg.type === "message" && msg.role === "assistant",
|
||||
);
|
||||
|
||||
const lastToolResponseIndex = findLastMessageIndex(
|
||||
messages,
|
||||
(msg) => msg.type === "tool_response",
|
||||
);
|
||||
|
||||
return messages.map((message, index) => {
|
||||
// Skip agent_output tool_responses that should be rendered inside assistant messages
|
||||
if (shouldSkipAgentOutput(message, messages[index - 1])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render last tool_response as AIChatBubble
|
||||
if (
|
||||
message.type === "tool_response" &&
|
||||
index === lastToolResponseIndex
|
||||
) {
|
||||
return (
|
||||
<LastToolResponse
|
||||
key={index}
|
||||
message={message}
|
||||
prevMessage={messages[index - 1]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageItem
|
||||
key={index}
|
||||
message={message}
|
||||
messages={messages}
|
||||
index={index}
|
||||
lastAssistantMessageIndex={lastAssistantMessageIndex}
|
||||
onSendMessage={onSendMessage}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{/* Render thinking message when streaming but no chunks yet */}
|
||||
{isStreaming && streamingChunks.length === 0 && <ThinkingMessage />}
|
||||
|
||||
{/* Render streaming message if active */}
|
||||
{isStreaming && streamingChunks.length > 0 && (
|
||||
<StreamingMessage
|
||||
chunks={streamingChunks}
|
||||
onComplete={onStreamComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Invisible div to scroll to */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { AIChatBubble } from "../../../AIChatBubble/AIChatBubble";
|
||||
import type { ChatMessageData } from "../../../ChatMessage/useChatMessage";
|
||||
import { MarkdownContent } from "../../../MarkdownContent/MarkdownContent";
|
||||
import { formatToolResultValue, shouldSkipAgentOutput } from "../../helpers";
|
||||
|
||||
export interface LastToolResponseProps {
|
||||
message: ChatMessageData;
|
||||
prevMessage: ChatMessageData | undefined;
|
||||
}
|
||||
|
||||
export function LastToolResponse({
|
||||
message,
|
||||
prevMessage,
|
||||
}: LastToolResponseProps) {
|
||||
if (message.type !== "tool_response") return null;
|
||||
|
||||
// Skip if this is an agent_output that should be rendered inside assistant message
|
||||
if (shouldSkipAgentOutput(message, prevMessage)) return null;
|
||||
|
||||
const resultValue = formatToolResultValue(message.result);
|
||||
|
||||
return (
|
||||
<div className="min-w-0 overflow-x-hidden hyphens-auto break-words px-4 py-2">
|
||||
<AIChatBubble>
|
||||
<MarkdownContent content={resultValue} />
|
||||
</AIChatBubble>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ChatMessage } from "../../../ChatMessage/ChatMessage";
|
||||
import type { ChatMessageData } from "../../../ChatMessage/useChatMessage";
|
||||
import { useMessageItem } from "./useMessageItem";
|
||||
|
||||
export interface MessageItemProps {
|
||||
message: ChatMessageData;
|
||||
messages: ChatMessageData[];
|
||||
index: number;
|
||||
lastAssistantMessageIndex: number;
|
||||
onSendMessage?: (content: string) => void;
|
||||
}
|
||||
|
||||
export function MessageItem({
|
||||
message,
|
||||
messages,
|
||||
index,
|
||||
lastAssistantMessageIndex,
|
||||
onSendMessage,
|
||||
}: MessageItemProps) {
|
||||
const { messageToRender, agentOutput, isFinalMessage } = useMessageItem({
|
||||
message,
|
||||
messages,
|
||||
index,
|
||||
lastAssistantMessageIndex,
|
||||
});
|
||||
|
||||
return (
|
||||
<ChatMessage
|
||||
message={messageToRender}
|
||||
onSendMessage={onSendMessage}
|
||||
agentOutput={agentOutput}
|
||||
isFinalMessage={isFinalMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { ChatMessageData } from "../../../ChatMessage/useChatMessage";
|
||||
import { isAgentOutputResult, isToolOutputPattern } from "../../helpers";
|
||||
|
||||
export interface UseMessageItemArgs {
|
||||
message: ChatMessageData;
|
||||
messages: ChatMessageData[];
|
||||
index: number;
|
||||
lastAssistantMessageIndex: number;
|
||||
}
|
||||
|
||||
export function useMessageItem({
|
||||
message,
|
||||
messages,
|
||||
index,
|
||||
lastAssistantMessageIndex,
|
||||
}: UseMessageItemArgs) {
|
||||
let agentOutput: ChatMessageData | undefined;
|
||||
let messageToRender: ChatMessageData = message;
|
||||
|
||||
// Check if assistant message follows a tool_call and looks like a tool output
|
||||
if (message.type === "message" && message.role === "assistant") {
|
||||
const prevMessage = messages[index - 1];
|
||||
|
||||
// Check if next message is an agent_output tool_response to include in current assistant message
|
||||
const nextMessage = messages[index + 1];
|
||||
if (
|
||||
nextMessage &&
|
||||
nextMessage.type === "tool_response" &&
|
||||
nextMessage.result
|
||||
) {
|
||||
if (isAgentOutputResult(nextMessage.result)) {
|
||||
agentOutput = nextMessage;
|
||||
}
|
||||
}
|
||||
|
||||
// Only convert to tool_response if it follows a tool_call AND looks like a tool output
|
||||
if (prevMessage && prevMessage.type === "tool_call") {
|
||||
if (isToolOutputPattern(message.content)) {
|
||||
// Convert this message to a tool_response format for rendering
|
||||
messageToRender = {
|
||||
type: "tool_response",
|
||||
toolId: prevMessage.toolId,
|
||||
toolName: prevMessage.toolName,
|
||||
result: message.content,
|
||||
success: true,
|
||||
timestamp: message.timestamp,
|
||||
} as ChatMessageData;
|
||||
|
||||
console.log(
|
||||
"[MessageItem] Converting assistant message to tool output:",
|
||||
{
|
||||
content: message.content.substring(0, 100),
|
||||
prevToolName: prevMessage.toolName,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Log for debugging
|
||||
if (message.type === "message" && message.role === "assistant") {
|
||||
const prevMessageToolName =
|
||||
prevMessage?.type === "tool_call" ? prevMessage.toolName : undefined;
|
||||
console.log("[MessageItem] Assistant message:", {
|
||||
index,
|
||||
content: message.content.substring(0, 200),
|
||||
fullContent: message.content,
|
||||
prevMessageType: prevMessage?.type,
|
||||
prevMessageToolName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const isFinalMessage =
|
||||
messageToRender.type !== "message" ||
|
||||
messageToRender.role !== "assistant" ||
|
||||
index === lastAssistantMessageIndex;
|
||||
|
||||
return {
|
||||
messageToRender,
|
||||
agentOutput,
|
||||
isFinalMessage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
||||
|
||||
export function parseToolResult(
|
||||
result: unknown,
|
||||
): Record<string, unknown> | null {
|
||||
try {
|
||||
return typeof result === "string"
|
||||
? JSON.parse(result)
|
||||
: (result as Record<string, unknown>);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isAgentOutputResult(result: unknown): boolean {
|
||||
const parsed = parseToolResult(result);
|
||||
return parsed?.type === "agent_output";
|
||||
}
|
||||
|
||||
export function isToolOutputPattern(content: string): boolean {
|
||||
const normalizedContent = content.toLowerCase().trim();
|
||||
|
||||
return (
|
||||
normalizedContent.startsWith("no agents found") ||
|
||||
normalizedContent.startsWith("no results found") ||
|
||||
normalizedContent.includes("no agents found matching") ||
|
||||
!!normalizedContent.match(/^no \w+ found/i) ||
|
||||
(content.length < 150 && normalizedContent.includes("try different")) ||
|
||||
(content.length < 200 &&
|
||||
!normalizedContent.includes("i'll") &&
|
||||
!normalizedContent.includes("let me") &&
|
||||
!normalizedContent.includes("i can") &&
|
||||
!normalizedContent.includes("i will"))
|
||||
);
|
||||
}
|
||||
|
||||
export function formatToolResultValue(result: unknown): string {
|
||||
return typeof result === "string"
|
||||
? result
|
||||
: result
|
||||
? JSON.stringify(result, null, 2)
|
||||
: "";
|
||||
}
|
||||
|
||||
export function findLastMessageIndex(
|
||||
messages: ChatMessageData[],
|
||||
predicate: (msg: ChatMessageData) => boolean,
|
||||
): number {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (predicate(messages[i])) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export function shouldSkipAgentOutput(
|
||||
message: ChatMessageData,
|
||||
prevMessage: ChatMessageData | undefined,
|
||||
): boolean {
|
||||
if (message.type !== "tool_response" || !message.result) return false;
|
||||
|
||||
const isAgentOutput = isAgentOutputResult(message.result);
|
||||
return (
|
||||
isAgentOutput &&
|
||||
!!prevMessage &&
|
||||
prevMessage.type === "message" &&
|
||||
prevMessage.role === "assistant"
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RobotIcon } from "@phosphor-icons/react";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
import { MarkdownContent } from "../MarkdownContent/MarkdownContent";
|
||||
import { MessageBubble } from "../MessageBubble/MessageBubble";
|
||||
import { useStreamingMessage } from "./useStreamingMessage";
|
||||
|
||||
export interface StreamingMessageProps {
|
||||
@@ -25,16 +24,10 @@ export function StreamingMessage({
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full max-w-3xl gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-600">
|
||||
<RobotIcon className="h-4 w-4 text-indigo-50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<MessageBubble variant="assistant">
|
||||
<AIChatBubble>
|
||||
<MarkdownContent content={displayText} />
|
||||
</MessageBubble>
|
||||
</AIChatBubble>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +1,6 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { RobotIcon } from "@phosphor-icons/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MessageBubble } from "../MessageBubble/MessageBubble";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
|
||||
export interface ThinkingMessageProps {
|
||||
className?: string;
|
||||
@@ -34,14 +33,8 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full max-w-3xl gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-500">
|
||||
<RobotIcon className="h-4 w-4 text-indigo-50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<MessageBubble variant="assistant">
|
||||
<AIChatBubble>
|
||||
<div className="transition-all duration-500 ease-in-out">
|
||||
{showSlowLoader ? (
|
||||
<div className="flex flex-col items-center gap-3 py-2">
|
||||
@@ -62,7 +55,7 @@ export function ThinkingMessage({ className }: ThinkingMessageProps) {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</MessageBubble>
|
||||
</AIChatBubble>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import type { ToolArguments } from "@/types/chat";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
|
||||
export interface ToolCallMessageProps {
|
||||
toolId?: string;
|
||||
toolName: string;
|
||||
arguments?: ToolArguments;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolCallMessage({
|
||||
toolId,
|
||||
toolName,
|
||||
arguments: toolArguments,
|
||||
className,
|
||||
}: ToolCallMessageProps) {
|
||||
const displayKey = toolName || toolId;
|
||||
|
||||
const displayData = toolArguments
|
||||
? JSON.stringify(toolArguments)
|
||||
: "No arguments";
|
||||
|
||||
const displayText = `${displayKey}: ${displayData}`;
|
||||
|
||||
return (
|
||||
<AIChatBubble className={className}>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{displayText}
|
||||
</Text>
|
||||
</AIChatBubble>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Text } from "@/components/atoms/Text/Text";
|
||||
import type { ToolResult } from "@/types/chat";
|
||||
import { AIChatBubble } from "../AIChatBubble/AIChatBubble";
|
||||
|
||||
export interface ToolResponseMessageProps {
|
||||
toolId?: string;
|
||||
toolName: string;
|
||||
result?: ToolResult;
|
||||
success?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ToolResponseMessage({
|
||||
toolId,
|
||||
toolName,
|
||||
result: _result,
|
||||
success: _success = true,
|
||||
className,
|
||||
}: ToolResponseMessageProps) {
|
||||
const displayKey = toolId || toolName;
|
||||
const resultValue =
|
||||
typeof _result === "string"
|
||||
? _result
|
||||
: _result
|
||||
? JSON.stringify(_result)
|
||||
: toolName;
|
||||
const displayText = `${displayKey}: ${resultValue}`;
|
||||
|
||||
return (
|
||||
<AIChatBubble className={className}>
|
||||
<Text variant="small" className="text-neutral-500">
|
||||
{displayText}
|
||||
</Text>
|
||||
</AIChatBubble>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface UserChatBubbleProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function UserChatBubble({ children, className }: UserChatBubbleProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative min-w-20 overflow-hidden rounded-xl bg-purple-100 px-3 text-right text-sm leading-relaxed transition-all duration-500 ease-in-out",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
borderBottomRightRadius: 0,
|
||||
}}
|
||||
>
|
||||
<div className="relative z-10 text-slate-900 transition-all duration-500 ease-in-out">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useChatSession } from "./useChatSession";
|
||||
import { useChatStream } from "./useChatStream";
|
||||
|
||||
export function useChat() {
|
||||
const hasCreatedSessionRef = useRef(false);
|
||||
interface UseChatArgs {
|
||||
urlSessionId?: string | null;
|
||||
}
|
||||
|
||||
export function useChat({ urlSessionId }: UseChatArgs = {}) {
|
||||
const hasClaimedSessionRef = useRef(false);
|
||||
const { user } = useSupabase();
|
||||
const { sendMessage: sendStreamMessage } = useChatStream();
|
||||
|
||||
const [showLoader, setShowLoader] = useState(false);
|
||||
const {
|
||||
session,
|
||||
sessionId: sessionIdFromHook,
|
||||
@@ -24,22 +27,10 @@ export function useChat() {
|
||||
clearSession: clearSessionBase,
|
||||
loadSession,
|
||||
} = useChatSession({
|
||||
urlSessionId: null,
|
||||
urlSessionId,
|
||||
autoCreate: false,
|
||||
});
|
||||
|
||||
useEffect(
|
||||
function autoCreateSession() {
|
||||
if (!hasCreatedSessionRef.current && !isCreating && !sessionIdFromHook) {
|
||||
hasCreatedSessionRef.current = true;
|
||||
createSession().catch((_err) => {
|
||||
hasCreatedSessionRef.current = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
[isCreating, sessionIdFromHook, createSession],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
function autoClaimSession() {
|
||||
if (
|
||||
@@ -75,6 +66,17 @@ export function useChat() {
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isCreating) {
|
||||
const timer = setTimeout(() => {
|
||||
setShowLoader(true);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setShowLoader(false);
|
||||
}
|
||||
}, [isLoading, isCreating]);
|
||||
|
||||
useEffect(function monitorNetworkStatus() {
|
||||
function handleOnline() {
|
||||
toast.success("Connection restored", {
|
||||
@@ -99,7 +101,6 @@ export function useChat() {
|
||||
|
||||
function clearSession() {
|
||||
clearSessionBase();
|
||||
hasCreatedSessionRef.current = false;
|
||||
hasClaimedSessionRef.current = false;
|
||||
}
|
||||
|
||||
@@ -113,5 +114,6 @@ export function useChat() {
|
||||
clearSession,
|
||||
loadSession,
|
||||
sessionId: sessionIdFromHook,
|
||||
showLoader,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { useGetV2GetMyAgents } from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
|
||||
export interface Agent {
|
||||
name: string;
|
||||
@@ -36,6 +37,7 @@ export function useAgentSelectStep({
|
||||
const [selectedAgentVersion, setSelectedAgentVersion] = React.useState<
|
||||
number | null
|
||||
>(null);
|
||||
const { isLoggedIn } = useSupabase();
|
||||
|
||||
const {
|
||||
data: _myAgents,
|
||||
@@ -43,6 +45,7 @@ export function useAgentSelectStep({
|
||||
error,
|
||||
} = useGetV2GetMyAgents(undefined, {
|
||||
query: {
|
||||
enabled: isLoggedIn,
|
||||
select: (res) =>
|
||||
okData(res)
|
||||
?.agents.map(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import type { MyAgent } from "@/app/api/__generated__/models/myAgent";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
|
||||
const defaultTargetState: PublishState = {
|
||||
isOpen: false,
|
||||
@@ -68,10 +69,19 @@ export function usePublishAgentModal({
|
||||
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { isLoggedIn } = useSupabase();
|
||||
|
||||
// Fetch agent data for pre-populating form when agent is pre-selected
|
||||
const { data: myAgents } = useGetV2GetMyAgents();
|
||||
const { data: mySubmissions } = useGetV2ListMySubmissions();
|
||||
const { data: myAgents } = useGetV2GetMyAgents(undefined, {
|
||||
query: {
|
||||
enabled: isLoggedIn,
|
||||
},
|
||||
});
|
||||
const { data: mySubmissions } = useGetV2ListMySubmissions(undefined, {
|
||||
query: {
|
||||
enabled: isLoggedIn,
|
||||
},
|
||||
});
|
||||
|
||||
// Sync currentState with targetState when it changes from outside
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { IconAutoGPTLogo, IconType } from "@/components/__legacy__/ui/icons";
|
||||
import { PreviewBanner } from "@/components/layout/Navbar/components/PreviewBanner/PreviewBanner";
|
||||
import { isLogoutInProgress } from "@/lib/autogpt-server-api/helpers";
|
||||
import { NAVBAR_HEIGHT_PX } from "@/lib/constants";
|
||||
import { useBreakpoint } from "@/lib/hooks/useBreakpoint";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { environment } from "@/services/environment";
|
||||
@@ -24,12 +26,13 @@ export function Navbar() {
|
||||
const dynamicMenuItems = getAccountMenuItems(user?.role);
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
const previewBranchName = environment.getPreviewStealingDev();
|
||||
const logoutInProgress = isLogoutInProgress();
|
||||
|
||||
const { data: profile, isLoading: isProfileLoading } = useGetV2GetUserProfile(
|
||||
{
|
||||
query: {
|
||||
select: okData,
|
||||
enabled: isLoggedIn && !!user,
|
||||
enabled: isLoggedIn && !!user && !logoutInProgress,
|
||||
// Include user ID in query key to ensure cache invalidation when user changes
|
||||
queryKey: ["/api/store/profile", user?.id],
|
||||
},
|
||||
@@ -40,10 +43,13 @@ export function Navbar() {
|
||||
|
||||
const shouldShowPreviewBanner = Boolean(isLoggedIn && previewBranchName);
|
||||
|
||||
const actualLoggedInLinks =
|
||||
isChatEnabled === true
|
||||
? loggedInLinks.concat([{ name: "Chat", href: "/chat" }])
|
||||
: loggedInLinks;
|
||||
const homeHref = isChatEnabled === true ? "/copilot" : "/library";
|
||||
|
||||
const actualLoggedInLinks = [
|
||||
{ name: "Home", href: homeHref },
|
||||
...(isChatEnabled === true ? [{ name: "Tasks", href: "/library" }] : []),
|
||||
...loggedInLinks,
|
||||
];
|
||||
|
||||
if (isUserLoading) {
|
||||
return <NavbarLoading />;
|
||||
@@ -55,7 +61,10 @@ export function Navbar() {
|
||||
{shouldShowPreviewBanner && previewBranchName ? (
|
||||
<PreviewBanner branchName={previewBranchName} />
|
||||
) : null}
|
||||
<nav className="border-zinc-[#EFEFF0] inline-flex h-[60px] w-full items-center border border-[#EFEFF0] bg-[#F3F4F6]/20 p-3 backdrop-blur-[26px]">
|
||||
<nav
|
||||
className="border-zinc-[#EFEFF0] inline-flex w-full items-center border border-[#EFEFF0] bg-[#F3F4F6]/20 p-3 backdrop-blur-[26px]"
|
||||
style={{ height: NAVBAR_HEIGHT_PX }}
|
||||
>
|
||||
{/* Left section */}
|
||||
{!isSmallScreen ? (
|
||||
<div className="flex flex-1 items-center gap-5">
|
||||
@@ -117,21 +126,17 @@ export function Navbar() {
|
||||
groupName: "Navigation",
|
||||
items: actualLoggedInLinks
|
||||
.map((link) => {
|
||||
if (link.name === "Chat" && !isChatEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
icon:
|
||||
link.name === "Marketplace"
|
||||
link.href === "/marketplace"
|
||||
? IconType.Marketplace
|
||||
: link.name === "Library"
|
||||
? IconType.Library
|
||||
: link.name === "Build"
|
||||
? IconType.Builder
|
||||
: link.name === "Chat"
|
||||
? IconType.Chat
|
||||
: link.name === "Monitor"
|
||||
: link.href === "/build"
|
||||
? IconType.Builder
|
||||
: link.href === "/copilot"
|
||||
? IconType.Chat
|
||||
: link.href === "/library"
|
||||
? IconType.Library
|
||||
: link.href === "/monitor"
|
||||
? IconType.Library
|
||||
: IconType.LayoutDashboard,
|
||||
text: link.name,
|
||||
|
||||
@@ -1,58 +1,27 @@
|
||||
"use client";
|
||||
import { IconLogOut } from "@/components/__legacy__/ui/icons";
|
||||
import { LoadingSpinner } from "@/components/__legacy__/ui/loading";
|
||||
import { useToast } from "@/components/molecules/Toast/use-toast";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { cn } from "@/lib/utils";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTransition } from "react";
|
||||
|
||||
export function AccountLogoutOption() {
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const supabase = useSupabase();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
|
||||
function handleLogout() {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await supabase.logOut();
|
||||
router.replace("/login");
|
||||
} catch (e) {
|
||||
Sentry.captureException(e);
|
||||
toast({
|
||||
title: "Error logging out",
|
||||
description:
|
||||
"Something went wrong when logging out. Please try again. If the problem persists, please contact support.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
});
|
||||
async function handleLogout() {
|
||||
router.replace("/logout");
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex w-full items-center justify-start gap-2.5",
|
||||
isPending && "justify-center opacity-50",
|
||||
)}
|
||||
className="inline-flex w-full items-center justify-start gap-2.5"
|
||||
onClick={handleLogout}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{isPending ? (
|
||||
<LoadingSpinner className="size-5" />
|
||||
) : (
|
||||
<>
|
||||
<div className="relative h-4 w-4">
|
||||
<IconLogOut />
|
||||
</div>
|
||||
<div className="font-sans text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
|
||||
Log out
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="relative h-4 w-4">
|
||||
<IconLogOut />
|
||||
</div>
|
||||
<div className="font-sans text-base font-medium leading-normal text-neutral-800 dark:text-neutral-200">
|
||||
Log out
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,17 +7,18 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/__legacy__/ui/popover";
|
||||
import { Separator } from "@/components/__legacy__/ui/separator";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { usePathname } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { MenuItemGroup } from "../../helpers";
|
||||
import { MobileNavbarMenuItem } from "./components/MobileNavbarMenuItem";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { CaretUpIcon, ListIcon } from "@phosphor-icons/react";
|
||||
import Avatar, {
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/atoms/Avatar/Avatar";
|
||||
import { Button } from "@/components/atoms/Button/Button";
|
||||
import { CaretUpIcon, ListIcon } from "@phosphor-icons/react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { usePathname } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { MenuItemGroup } from "../../helpers";
|
||||
import { MobileNavbarLogoutItem } from "./components/MobileNavbarLogoutItem";
|
||||
import { MobileNavbarMenuItem } from "./components/MobileNavbarMenuItem";
|
||||
|
||||
interface MobileNavBarProps {
|
||||
userName?: string;
|
||||
@@ -96,16 +97,27 @@ export function MobileNavBar({
|
||||
<Separator className="mb-4" />
|
||||
{menuItemGroups.map((group, groupIndex) => (
|
||||
<React.Fragment key={groupIndex}>
|
||||
{group.items.map((item, itemIndex) => (
|
||||
<MobileNavbarMenuItem
|
||||
key={itemIndex}
|
||||
icon={item.icon}
|
||||
isActive={item.href === activeLink}
|
||||
text={item.text}
|
||||
onClick={item.onClick}
|
||||
href={item.href}
|
||||
/>
|
||||
))}
|
||||
{group.items.map((item, itemIndex) => {
|
||||
if (item.text === "Log out") {
|
||||
return (
|
||||
<MobileNavbarLogoutItem
|
||||
key={itemIndex}
|
||||
icon={item.icon}
|
||||
text={item.text}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MobileNavbarMenuItem
|
||||
key={itemIndex}
|
||||
icon={item.icon}
|
||||
isActive={item.href === activeLink}
|
||||
text={item.text}
|
||||
onClick={item.onClick}
|
||||
href={item.href}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{groupIndex < menuItemGroups.length - 1 && (
|
||||
<Separator className="my-4" />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { IconType } from "@/components/__legacy__/ui/icons";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { getAccountMenuOptionIcon } from "../../../helpers";
|
||||
|
||||
interface Props {
|
||||
icon: IconType;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function MobileNavbarLogoutItem({ icon, text }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogout() {
|
||||
router.replace("/logout");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full" onClick={handleLogout}>
|
||||
<div className="inline-flex w-full items-center justify-start gap-4 py-2 hover:rounded hover:bg-[#e0e0e0]">
|
||||
{getAccountMenuOptionIcon(icon)}
|
||||
<div className="relative">
|
||||
<div className="font-sans text-base font-normal leading-7 text-[#474747]">
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { IconLaptop } from "@/components/__legacy__/ui/icons";
|
||||
import { getHomepageRoute } from "@/lib/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import {
|
||||
ChatsIcon,
|
||||
CubeIcon,
|
||||
HouseIcon,
|
||||
ListChecksIcon,
|
||||
StorefrontIcon,
|
||||
} from "@phosphor-icons/react/dist/ssr";
|
||||
import Link from "next/link";
|
||||
@@ -22,8 +23,13 @@ interface Props {
|
||||
|
||||
export function NavbarLink({ name, href }: Props) {
|
||||
const pathname = usePathname();
|
||||
const isActive = pathname.includes(href);
|
||||
const chat_enabled = useGetFlag(Flag.CHAT);
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
const homepageRoute = getHomepageRoute(isChatEnabled);
|
||||
|
||||
const isActive =
|
||||
href === homepageRoute
|
||||
? pathname === "/" || pathname.startsWith(homepageRoute)
|
||||
: pathname.includes(href);
|
||||
|
||||
return (
|
||||
<Link href={href} data-testid={`navbar-link-${name.toLowerCase()}`}>
|
||||
@@ -58,7 +64,7 @@ export function NavbarLink({ name, href }: Props) {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{href === "/library" && (
|
||||
{href === "/copilot" && (
|
||||
<HouseIcon
|
||||
className={cn(
|
||||
iconWidthClass,
|
||||
@@ -66,14 +72,22 @@ export function NavbarLink({ name, href }: Props) {
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{chat_enabled && href === "/chat" && (
|
||||
<ChatsIcon
|
||||
className={cn(
|
||||
iconWidthClass,
|
||||
isActive && "text-white dark:text-black",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{href === "/library" &&
|
||||
(isChatEnabled ? (
|
||||
<ListChecksIcon
|
||||
className={cn(
|
||||
iconWidthClass,
|
||||
isActive && "text-white dark:text-black",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<HouseIcon
|
||||
className={cn(
|
||||
iconWidthClass,
|
||||
isActive && "text-white dark:text-black",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<Text
|
||||
variant="h5"
|
||||
className={cn(
|
||||
|
||||
@@ -22,10 +22,6 @@ export const loggedInLinks: Link[] = [
|
||||
name: "Marketplace",
|
||||
href: "/marketplace",
|
||||
},
|
||||
{
|
||||
name: "Library",
|
||||
href: "/library",
|
||||
},
|
||||
{
|
||||
name: "Build",
|
||||
href: "/build",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useGetV2GetUserProfile } from "@/app/api/__generated__/endpoints/store/store";
|
||||
import { isLogoutInProgress } from "@/lib/autogpt-server-api/helpers";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
|
||||
export function useNavbar() {
|
||||
const { isLoggedIn, isUserLoading } = useSupabase();
|
||||
const logoutInProgress = isLogoutInProgress();
|
||||
|
||||
console.log("isLoggedIn", isLoggedIn);
|
||||
|
||||
@@ -12,7 +14,7 @@ export function useNavbar() {
|
||||
error: profileError,
|
||||
} = useGetV2GetUserProfile({
|
||||
query: {
|
||||
enabled: isLoggedIn === true,
|
||||
enabled: isLoggedIn === true && !logoutInProgress,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ function Dialog({
|
||||
<RXDialog.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
if (!open && !forceOpen) {
|
||||
config.handleClose();
|
||||
onClose?.();
|
||||
}
|
||||
@@ -70,7 +70,7 @@ function Dialog({
|
||||
shouldScaleBackground
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
if (!open && !forceOpen) {
|
||||
config.handleClose();
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
@@ -40,29 +40,35 @@ export function DialogWrap({
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [hasVerticalScrollbar, setHasVerticalScrollbar] = useState(false);
|
||||
|
||||
// Prevent dialog from closing when external picker is open
|
||||
// Prevent dialog from closing when external picker is open or when forceOpen is true
|
||||
const handleInteractOutside = useCallback(
|
||||
(event: Event) => {
|
||||
if (isExternalPickerOpen()) {
|
||||
if (isExternalPickerOpen() || isForceOpen) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
},
|
||||
[handleClose],
|
||||
[handleClose, isForceOpen],
|
||||
);
|
||||
|
||||
const handlePointerDownOutside = useCallback((event: Event) => {
|
||||
if (isExternalPickerOpen()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
const handlePointerDownOutside = useCallback(
|
||||
(event: Event) => {
|
||||
if (isExternalPickerOpen() || isForceOpen) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[isForceOpen],
|
||||
);
|
||||
|
||||
const handleFocusOutside = useCallback((event: Event) => {
|
||||
if (isExternalPickerOpen()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
const handleFocusOutside = useCallback(
|
||||
(event: Event) => {
|
||||
if (isExternalPickerOpen() || isForceOpen) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
[isForceOpen],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
function update() {
|
||||
@@ -88,7 +94,7 @@ export function DialogWrap({
|
||||
onInteractOutside={handleInteractOutside}
|
||||
onPointerDownOutside={handlePointerDownOutside}
|
||||
onFocusOutside={handleFocusOutside}
|
||||
onEscapeKeyDown={handleClose}
|
||||
onEscapeKeyDown={isForceOpen ? undefined : handleClose}
|
||||
aria-describedby={undefined}
|
||||
className={modalStyles.content}
|
||||
style={{
|
||||
|
||||
@@ -75,7 +75,7 @@ export const colors = {
|
||||
},
|
||||
purple: {
|
||||
50: "#f1ebfe",
|
||||
100: "#d5c0fc",
|
||||
100: "#efe8fe",
|
||||
200: "#c0a1fa",
|
||||
300: "#a476f8",
|
||||
400: "#925cf7",
|
||||
|
||||
@@ -8,3 +8,12 @@ export const IMPERSONATION_STORAGE_KEY = "admin-impersonate-user-id";
|
||||
|
||||
// API key authentication
|
||||
export const API_KEY_HEADER_NAME = "X-API-Key";
|
||||
|
||||
// Layout
|
||||
export const NAVBAR_HEIGHT_PX = 60;
|
||||
|
||||
// Routes
|
||||
export function getHomepageRoute(isChatEnabled?: boolean | null): string {
|
||||
if (isChatEnabled === true) return "/copilot";
|
||||
return "/library";
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getHomepageRoute } from "@/lib/constants";
|
||||
import { environment } from "@/services/environment";
|
||||
import { Key, storage } from "@/services/storage/local-storage";
|
||||
import { type CookieOptions } from "@supabase/ssr";
|
||||
@@ -70,7 +71,7 @@ export function getRedirectPath(
|
||||
}
|
||||
|
||||
if (isAdminPage(path) && userRole !== "admin") {
|
||||
return "/marketplace";
|
||||
return getHomepageRoute();
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getHomepageRoute } from "@/lib/constants";
|
||||
import { environment } from "@/services/environment";
|
||||
import { createServerClient } from "@supabase/ssr";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
@@ -66,7 +67,7 @@ export async function updateSession(request: NextRequest) {
|
||||
|
||||
// 2. Check if user is authenticated but lacks admin role when accessing admin pages
|
||||
if (user && userRole !== "admin" && isAdminPage(pathname)) {
|
||||
url.pathname = "/marketplace";
|
||||
url.pathname = getHomepageRoute();
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
"use client";
|
||||
import {
|
||||
getV1IsOnboardingEnabled,
|
||||
getV1OnboardingState,
|
||||
patchV1UpdateOnboardingState,
|
||||
postV1CompleteOnboardingStep,
|
||||
} from "@/app/api/__generated__/endpoints/onboarding/onboarding";
|
||||
import { PostV1CompleteOnboardingStepStep } from "@/app/api/__generated__/models/postV1CompleteOnboardingStepStep";
|
||||
import { resolveResponse } from "@/app/api/helpers";
|
||||
import { Button } from "@/components/__legacy__/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -15,7 +23,9 @@ import {
|
||||
WebSocketNotification,
|
||||
} from "@/lib/autogpt-server-api";
|
||||
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
|
||||
import { getHomepageRoute } from "@/lib/constants";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -28,19 +38,11 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
updateOnboardingState,
|
||||
fromBackendUserOnboarding,
|
||||
shouldRedirectFromOnboarding,
|
||||
LocalOnboardingStateUpdate,
|
||||
shouldRedirectFromOnboarding,
|
||||
updateOnboardingState,
|
||||
} from "./helpers";
|
||||
import { resolveResponse } from "@/app/api/helpers";
|
||||
import {
|
||||
getV1IsOnboardingEnabled,
|
||||
getV1OnboardingState,
|
||||
patchV1UpdateOnboardingState,
|
||||
postV1CompleteOnboardingStep,
|
||||
} from "@/app/api/__generated__/endpoints/onboarding/onboarding";
|
||||
import { PostV1CompleteOnboardingStepStep } from "@/app/api/__generated__/models/postV1CompleteOnboardingStepStep";
|
||||
|
||||
type FrontendOnboardingStep = PostV1CompleteOnboardingStepStep;
|
||||
|
||||
@@ -102,6 +104,8 @@ export default function OnboardingProvider({
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { isLoggedIn } = useSupabase();
|
||||
const isChatEnabled = useGetFlag(Flag.CHAT);
|
||||
const homepageRoute = getHomepageRoute(isChatEnabled);
|
||||
|
||||
useOnboardingTimezoneDetection();
|
||||
|
||||
@@ -146,7 +150,7 @@ export default function OnboardingProvider({
|
||||
if (isOnOnboardingRoute) {
|
||||
const enabled = await resolveResponse(getV1IsOnboardingEnabled());
|
||||
if (!enabled) {
|
||||
router.push("/marketplace");
|
||||
router.push(homepageRoute);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -158,7 +162,7 @@ export default function OnboardingProvider({
|
||||
isOnOnboardingRoute &&
|
||||
shouldRedirectFromOnboarding(onboarding.completedSteps, pathname)
|
||||
) {
|
||||
router.push("/marketplace");
|
||||
router.push(homepageRoute);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize onboarding:", error);
|
||||
@@ -173,7 +177,7 @@ export default function OnboardingProvider({
|
||||
}
|
||||
|
||||
initializeOnboarding();
|
||||
}, [api, isOnOnboardingRoute, router, isLoggedIn, pathname]);
|
||||
}, [api, homepageRoute, isOnOnboardingRoute, router, isLoggedIn, pathname]);
|
||||
|
||||
const handleOnboardingNotification = useCallback(
|
||||
(notification: WebSocketNotification) => {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { LDProvider } from "launchdarkly-react-client-sdk";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { environment } from "../environment";
|
||||
|
||||
const clientId = process.env.NEXT_PUBLIC_LAUNCHDARKLY_CLIENT_ID;
|
||||
const envEnabled = process.env.NEXT_PUBLIC_LAUNCHDARKLY_ENABLED === "true";
|
||||
const LAUNCHDARKLY_INIT_TIMEOUT_MS = 5000;
|
||||
|
||||
export function LaunchDarklyProvider({ children }: { children: ReactNode }) {
|
||||
const { user, isUserLoading } = useSupabase();
|
||||
@@ -45,6 +46,7 @@ export function LaunchDarklyProvider({ children }: { children: ReactNode }) {
|
||||
key={context.key}
|
||||
clientSideID={clientId}
|
||||
context={context}
|
||||
timeout={LAUNCHDARKLY_INIT_TIMEOUT_MS}
|
||||
reactOptions={{ useCamelCaseFlagKeys: false }}
|
||||
options={{
|
||||
bootstrap: "localStorage",
|
||||
|
||||
@@ -172,7 +172,7 @@ test("multi-tab logout with WebSocket cleanup", async ({ context }) => {
|
||||
await page2.close();
|
||||
});
|
||||
|
||||
test("logged in user is redirected from /login to /marketplace", async ({
|
||||
test("logged in user is redirected from /login to /library", async ({
|
||||
page,
|
||||
}) => {
|
||||
const testUser = await getTestUser();
|
||||
@@ -182,10 +182,10 @@ test("logged in user is redirected from /login to /marketplace", async ({
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
await page.goto("/login");
|
||||
await hasUrl(page, "/marketplace");
|
||||
await hasUrl(page, "/library?sort=updatedAt");
|
||||
});
|
||||
|
||||
test("logged in user is redirected from /signup to /marketplace", async ({
|
||||
test("logged in user is redirected from /signup to /library", async ({
|
||||
page,
|
||||
}) => {
|
||||
const testUser = await getTestUser();
|
||||
@@ -195,5 +195,5 @@ test("logged in user is redirected from /signup to /marketplace", async ({
|
||||
await hasUrl(page, "/marketplace");
|
||||
|
||||
await page.goto("/signup");
|
||||
await hasUrl(page, "/marketplace");
|
||||
await hasUrl(page, "/library?sort=updatedAt");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user