Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions view/app/api/config/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export async function GET() {
baseUrl: process.env.API_URL || 'http://localhost:8080/api',
websocketUrl: process.env.WEBSOCKET_URL || 'ws://localhost:8080/ws',
webhookUrl: process.env.WEBHOOK_URL || 'http://localhost:8080/webhook',
octoagentUrl: process.env.OCTOAGENT_URL || 'http://localhost:9090',
port: process.env.NEXT_PUBLIC_PORT || '7443',
websiteDomain
});
Expand Down
42 changes: 42 additions & 0 deletions view/app/chat/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client';

import React, { useState, useCallback } from 'react';
import { AIContent } from '@/components/ai';
import { ThreadsSidebar } from '@/components/ai/threads-sidebar';

export default function ChatPage() {
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);

const handleThreadSelect = useCallback((threadId: string | null) => {
setSelectedThreadId(threadId);
}, []);

const handleNewThread = useCallback(() => {
setSelectedThreadId(null);
}, []);

const handleThreadChange = useCallback((threadId: string) => {
setSelectedThreadId(threadId);
}, []);

return (
<div className="flex h-[calc(100vh-4rem)] overflow-hidden bg-background">
<aside className="w-64 shrink-0 hidden md:flex flex-col h-full overflow-hidden">
<ThreadsSidebar
selectedThreadId={selectedThreadId}
onThreadSelect={handleThreadSelect}
onNewThread={handleNewThread}
className="h-full"
/>
</aside>
<main className="flex-1 flex flex-col h-full overflow-hidden min-w-0">
<AIContent
open={true}
className="h-full"
threadId={selectedThreadId}
onThreadChange={handleThreadChange}
/>
</main>
</div>
);
}
13 changes: 13 additions & 0 deletions view/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ export default function RootLayout({
const Layout = ({ children }: { children: React.ReactNode }) => {
return (
<html lang="en">
<head>
<script
dangerouslySetInnerHTML={{
__html: `
// Store native fetch before SuperTokens wraps it
// This allows us to bypass SuperTokens interception for octoagent requests
if (typeof window !== 'undefined' && !window.__NATIVE_FETCH__) {
window.__NATIVE_FETCH__ = window.fetch.bind(window);
}
`
}}
/>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
suppressHydrationWarning
Expand Down
199 changes: 199 additions & 0 deletions view/components/ai/ai-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
'use client';

import React from 'react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
import { useTranslation } from '@/hooks/use-translation';
import { Send, Loader2 } from 'lucide-react';
import { MessageBubble } from './message-bubble';
import { useAIChat } from '../../hooks/use-ai-chat';
import type { Message } from './types';
import { Sparkles } from 'lucide-react';
import { ArrowUpRight } from 'lucide-react';

interface AIContentProps {
open: boolean;
className?: string;
threadId?: string | null;
onThreadChange?: (threadId: string) => void;
}

function LoadingSkeletons() {
return (
<div className="space-y-6">
{[1, 2, 3].map((i) => {
const isEven = i % 2 === 0;
return (
<div key={i} className={`flex ${isEven ? 'justify-end' : 'justify-start'}`}>
<Skeleton className={`h-20 ${isEven ? 'w-2/3' : 'w-3/4'} rounded-2xl`} />
</div>
);
})}
</div>
);
}

function MessagesList({ messages, isStreaming }: { messages: Message[]; isStreaming: boolean }) {
return (
<div className="space-y-6">
{messages.map((message, index) => {
const isLastMessage = index === messages.length - 1;
const shouldShowStreaming = isStreaming && isLastMessage && message.role === 'assistant';
return (
<MessageBubble
key={message.id}
message={message}
isStreaming={shouldShowStreaming}
isLastMessage={isLastMessage}
/>
);
})}
</div>
);
}

function SendButtonIcon({ isStreaming }: { isStreaming: boolean }) {
if (isStreaming) {
return <Loader2 className="size-[18px] animate-spin" />;
}
return <Send className="size-[18px]" />;
}

export function AIContent({ open, className, threadId, onThreadChange }: AIContentProps) {
const { t } = useTranslation();
const {
messages,
inputValue,
isStreaming,
isLoadingMessages,
scrollRef,
textareaRef,
handleSubmit,
handleKeyDown,
handleSuggestionClick,
handleInputChange
} = useAIChat({ open, threadId, onThreadChange });

const renderContent = () => {
if (isLoadingMessages) {
return <LoadingSkeletons />;
}
if (messages.length === 0) {
return <EmptyState onSuggestionClick={handleSuggestionClick} />;
}
return <MessagesList messages={messages} isStreaming={isStreaming} />;
};

return (
<div className={`relative flex flex-col h-full overflow-hidden min-w-0 ${className || ''}`}>
<ScrollArea className="flex-1 min-h-0 min-w-0" ref={scrollRef}>
<div className="px-6 py-8 pb-6 min-w-0">{renderContent()}</div>
</ScrollArea>

<div className="shrink-0 bg-gradient-to-t from-background via-background to-background/80 px-4 py-4">
<form onSubmit={handleSubmit} className="max-w-3xl mx-auto">
<div className="flex items-end gap-3 bg-muted/40 border border-border/60 rounded-2xl px-4 py-3 focus-within:ring-2 focus-within:ring-primary/30 focus-within:border-primary/40 focus-within:bg-muted/60 transition-all duration-200 shadow-sm">
<Textarea
ref={textareaRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={t('ai.input.placeholder')}
className="flex-1 min-h-[40px] max-h-[160px] resize-none bg-transparent border-0 focus-visible:ring-0 focus-visible:ring-offset-0 overflow-y-auto px-1 py-[10px] text-sm leading-relaxed placeholder:text-muted-foreground/60"
disabled={isStreaming}
rows={1}
/>
<Button
type="submit"
size="icon"
disabled={!inputValue.trim() || isStreaming}
className="shrink-0 size-9 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground disabled:opacity-40 disabled:cursor-not-allowed transition-all duration-200 flex items-center justify-center mb-0.5 shadow-sm"
title={t('ai.input.sendMessage')}
>
<SendButtonIcon isStreaming={isStreaming} />
</Button>
</div>
<p className="text-[11px] text-muted-foreground/50 text-center mt-2.5">
{t('ai.input.hint')}
</p>
</form>
</div>
</div>
);
}

interface EmptyStateProps {
onSuggestionClick: (text: string) => void;
}

const SUGGESTION_KEYS = [
'ai.suggestions.deploy',
'ai.suggestions.logs',
'ai.suggestions.envVars'
] as const;

export function EmptyState({ onSuggestionClick }: EmptyStateProps) {
const { t } = useTranslation();

const suggestions = SUGGESTION_KEYS.map((key) => ({
key,
text: t(key)
})).filter((suggestion) => suggestion.text && suggestion.text.trim() !== '');

const handleSuggestionClick = (text: string) => {
if (onSuggestionClick && typeof onSuggestionClick === 'function') {
onSuggestionClick(text);
}
};

return (
<div className="flex flex-col items-center justify-center py-20 px-4">
<div className="relative mb-8">
<div className="absolute inset-0 bg-primary/20 rounded-3xl blur-xl" />
<div className="relative flex items-center justify-center size-20 rounded-2xl bg-gradient-to-br from-primary/20 to-primary/5 border border-primary/20">
<Sparkles className="size-9 text-primary" />
</div>
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">{t('ai.emptyState.title')}</h3>
<p className="text-sm text-muted-foreground text-center max-w-md leading-relaxed">
{t('ai.emptyState.description')}
</p>
{suggestions.length > 0 && (
<div className="mt-10 w-full max-w-md">
<p className="text-xs font-medium text-muted-foreground/70 uppercase tracking-wider mb-3 text-center">
{t('ai.emptyState.tryAskingAbout')}
</p>
<div className="grid grid-cols-1 gap-2">
{suggestions.map((suggestion) => (
<SuggestionChip
key={suggestion.key}
text={suggestion.text}
onClick={() => handleSuggestionClick(suggestion.text)}
/>
))}
</div>
</div>
)}
</div>
);
}

interface SuggestionChipProps {
text: string;
onClick: () => void;
}

function SuggestionChip({ text, onClick }: SuggestionChipProps) {
return (
<button
type="button"
onClick={onClick}
className="group flex items-center justify-between gap-3 px-4 py-3.5 text-sm text-left rounded-xl border border-border/40 bg-muted/20 hover:bg-muted/50 hover:border-primary/30 hover:shadow-sm transition-all duration-200 text-muted-foreground hover:text-foreground"
>
<span>{text}</span>
<ArrowUpRight className="size-4 opacity-0 -translate-x-1 group-hover:opacity-70 group-hover:translate-x-0 transition-all duration-200" />
</button>
);
}
56 changes: 2 additions & 54 deletions view/components/ai/ai-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,12 @@ interface AISheetProps {

export function AISheet({ open, onOpenChange }: AISheetProps) {
const { t } = useTranslation();
const {
messages,
inputValue,
isStreaming,
scrollRef,
textareaRef,
handleSubmit,
handleKeyDown,
handleSuggestionClick,
handleInputChange
} = useAIChat({ open });

return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="sm:max-w-xl w-full p-0 flex flex-col gap-0 bg-background/95 backdrop-blur-sm"
className="sm:max-w-xl w-full p-0 flex flex-col gap-0 bg-background/95 backdrop-blur-sm h-full max-h-screen overflow-hidden"
>
<SheetHeader className="px-6 py-4 border-b border-border/50 shrink-0">
<SheetTitle className="flex items-center gap-3 text-lg">
Expand All @@ -46,48 +35,7 @@ export function AISheet({ open, onOpenChange }: AISheetProps) {
</SheetTitle>
</SheetHeader>

<ScrollArea className="flex-1 min-h-0" ref={scrollRef}>
<div className="px-4 py-6">
{messages.length === 0 ? (
<EmptyState onSuggestionClick={handleSuggestionClick} />
) : (
<div className="space-y-6">
{messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
{isStreaming && <StreamingIndicator />}
</div>
)}
</div>
</ScrollArea>

<div className="shrink-0 border-t border-border/50 bg-background/80 backdrop-blur-sm p-4">
<form onSubmit={handleSubmit} className="flex gap-3 items-end">
<Textarea
ref={textareaRef}
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={t('ai.input.placeholder')}
className="min-h-[44px] max-h-[120px] resize-none bg-muted/50 border-border/50 focus-visible:ring-primary/30"
disabled={isStreaming}
rows={1}
/>
<Button
type="submit"
size="icon"
disabled={!inputValue.trim() || isStreaming}
className="shrink-0 size-11 rounded-lg"
>
{isStreaming ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</form>
<p className="text-xs text-muted-foreground mt-3 text-center">{t('ai.input.hint')}</p>
</div>
<AIContent open={open} className="flex-1 min-h-0 overflow-hidden" />
</SheetContent>
</Sheet>
);
Expand Down
2 changes: 2 additions & 0 deletions view/components/ai/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export { AISheet } from './ai-sheet';
export { AIContent } from './ai-content';
export { ThreadsSidebar } from './threads-sidebar';
Loading
Loading