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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/epics/src/common/ai-left-panel-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ export function AiLeftPanelLayout({ children }: AiLeftPanelLayoutProps) {
<>
<div
className={cn(
'relative flex h-full flex-shrink-0 flex-col transition-all duration-300',
panelOpen ? '' : 'w-0 overflow-hidden',
'relative flex h-full min-w-0 flex-shrink-0 flex-col overflow-hidden transition-all duration-300',
panelOpen ? '' : 'w-0',
)}
style={panelOpen ? { width: panelWidth } : undefined}
>
Expand Down
60 changes: 46 additions & 14 deletions packages/epics/src/common/ai-left-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,66 @@
'use client';

import { PanelLeftClose } from 'lucide-react';
import { useState } from 'react';

import { cn } from '@hypha-platform/ui-utils';

import {
AiPanelHeader,
AiPanelMessages,
AiPanelChatBar,
MOCK_MODEL_OPTIONS,
MOCK_SUGGESTIONS,
MOCK_WELCOME_MESSAGE,
} from './ai-panel';

type AiLeftPanelProps = {
onClose: () => void;
className?: string;
};

export function AiLeftPanel({ onClose, className }: AiLeftPanelProps) {
const [input, setInput] = useState('');
const [selectedModel, setSelectedModel] = useState(MOCK_MODEL_OPTIONS[0]!);
const [messages, setMessages] = useState([MOCK_WELCOME_MESSAGE]);
const [showSuggestions, setShowSuggestions] = useState(true);
const [isStreaming, setIsStreaming] = useState(false);

const handleSend = () => {
// Placeholder - no business logic
};

const handleSuggestionSelect = (text: string) => {
setShowSuggestions(false);
setInput(text);
};

return (
<div
className={cn(
'flex h-full flex-col bg-background-2 border-r border-border',
'flex h-full min-w-0 flex-col overflow-hidden border-r border-border bg-background-2',
className,
)}
>
<div className="flex flex-shrink-0 items-center justify-end border-b border-border px-4 py-3">
<button
type="button"
onClick={onClose}
className="flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Close panel"
aria-label="Close panel"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex-1 overflow-auto" />
<AiPanelHeader
onClose={onClose}
modelOptions={MOCK_MODEL_OPTIONS}
selectedModel={selectedModel}
onModelSelect={setSelectedModel}
/>

<AiPanelMessages
messages={messages}
suggestions={MOCK_SUGGESTIONS}
showSuggestions={showSuggestions}
onSuggestionSelect={handleSuggestionSelect}
/>

<AiPanelChatBar
value={input}
onChange={setInput}
onSend={handleSend}
isStreaming={isStreaming}
/>
</div>
);
}
154 changes: 154 additions & 0 deletions packages/epics/src/common/ai-panel/ai-panel-chat-bar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
'use client';

import { useRef } from 'react';
import {
Code2,
Image,
Mic,
Paperclip,
Search,
Send,
Square,
} from 'lucide-react';

import { cn } from '@hypha-platform/ui-utils';

type AiPanelChatBarProps = {
value: string;
onChange: (value: string) => void;
onSend: () => void;
isStreaming?: boolean;
placeholder?: string;
};

export function AiPanelChatBar({
value,
onChange,
onSend,
isStreaming = false,
placeholder = 'Ask Hypha AI anything...',
}: AiPanelChatBarProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);

const autoResize = () => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height =
Math.min(textareaRef.current.scrollHeight, 160) + 'px';
}
};
Comment on lines +31 to +39
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Textarea height can get stale after controlled value updates.

autoResize() only runs on local typing. If parent state clears or replaces value (e.g., after send), height may not shrink until next keystroke.

Proposed fix
-import { useRef } from 'react';
+import { useEffect, useRef } from 'react';
@@
   const autoResize = () => {
@@
   };
+
+  useEffect(() => {
+    autoResize();
+  }, [value]);

Also applies to: 107-110, 119-120

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/epics/src/common/ai-panel/ai-panel-chat-bar.tsx` around lines 31 -
39, The textarea height can become stale because autoResize only runs on local
typing; add a useEffect that calls the existing autoResize function whenever the
controlled value prop changes (the prop/variable named value used to render the
textarea) so that height shrinks/expands after parent updates (e.g., after
send); ensure you reference the textareaRef and autoResize functions inside that
useEffect and also call autoResize after any programmatic value resets (where
send/clear logic runs) to keep behavior consistent.


const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSend();
}
};

const canSend = value.trim().length > 0 && !isStreaming;

return (
<div className="flex w-full min-w-0 flex-shrink-0 flex-col border-t border-border bg-background-2 p-3">
<div
className={cn(
'flex min-w-0 flex-col rounded-2xl border border-border bg-muted/50',
'transition-all duration-200 focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/20',
)}
>
{/* Toolbar */}
<div className="flex min-w-0 items-center gap-1 px-3 pt-2.5">
<button
type="button"
className="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Attach file"
aria-label="Attach file"
>
<Paperclip className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Image"
aria-label="Add image"
>
<Image className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Code"
aria-label="Add code snippet"
>
<Code2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Search web"
aria-label="Search"
>
<Search className="h-3.5 w-3.5" />
</button>
<div className="min-w-0 flex-1" />
<button
type="button"
className="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Voice"
aria-label="Voice input"
>
<Mic className="h-3.5 w-3.5" />
</button>
</div>

{/* Textarea — full width */}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
onChange(e.target.value);
autoResize();
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={1}
className={cn(
'min-h-[36px] min-w-0 max-h-[160px] w-full resize-none',
'bg-transparent px-3 py-2 text-sm leading-relaxed text-foreground',
'placeholder:text-muted-foreground focus:outline-none',
)}
style={{ minHeight: '36px', maxHeight: '160px' }}
/>

{/* Bottom bar */}
<div className="flex min-w-0 flex-wrap items-center justify-between gap-x-2 gap-y-1 px-3 pb-2.5">
<span className="min-w-0 break-words text-xs text-muted-foreground">
Shift+Enter for newline
</span>
<button
type="button"
onClick={onSend}
disabled={!canSend}
className={cn(
'flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs font-medium transition-all duration-200',
canSend
? 'bg-primary text-primary-foreground hover:opacity-90'
: 'cursor-not-allowed bg-muted text-muted-foreground',
)}
>
{isStreaming ? (
<>
<Square className="h-3 w-3" />
Stop
</>
) : (
<>
<Send className="h-3 w-3" />
Send
</>
)}
</button>
</div>
</div>
</div>
);
}
107 changes: 107 additions & 0 deletions packages/epics/src/common/ai-panel/ai-panel-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
'use client';

import { useEffect, useRef, useState } from 'react';
import { ChevronDown, PanelLeftClose, RefreshCw, Sparkles } from 'lucide-react';

import { cn } from '@hypha-platform/ui-utils';

import type { ModelOption } from './mock-data';

type AiPanelHeaderProps = {
onClose: () => void;
modelOptions: ModelOption[];
selectedModel: ModelOption;
onModelSelect: (model: ModelOption) => void;
};

export function AiPanelHeader({
onClose,
modelOptions,
selectedModel,
onModelSelect,
}: AiPanelHeaderProps) {
const [showModelMenu, setShowModelMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const SelectedIcon = selectedModel.icon;

useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
showModelMenu &&
menuRef.current &&
!menuRef.current.contains(e.target as Node)
) {
setShowModelMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showModelMenu]);

return (
<div className="flex min-w-0 flex-shrink-0 flex-wrap items-center justify-between gap-x-2 gap-y-2 border-b border-border bg-background-2 px-4 py-3">
<div className="flex min-w-0 shrink-0 items-center gap-2">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-xl bg-primary">
<Sparkles className="h-3.5 w-3.5 text-primary-foreground" />
</div>
<span className="font-semibold text-sm text-foreground">Hypha AI</span>
</div>
<div className="flex min-w-0 flex-wrap items-center justify-end gap-1">
<div className="relative" ref={menuRef}>
<button
type="button"
onClick={() => setShowModelMenu(!showModelMenu)}
className="flex min-w-0 items-center gap-1.5 rounded-lg border border-border bg-secondary px-2.5 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<SelectedIcon className="h-3 w-3 shrink-0" />
<span className="min-w-0 truncate">{selectedModel.label}</span>
<ChevronDown className="h-3 w-3 shrink-0" />
</button>
{showModelMenu && (
<div className="absolute left-0 top-full z-50 mt-1 w-44 animate-in fade-in slide-in-from-top-2 rounded-xl border border-border bg-popover py-1 shadow-2xl duration-200">
{modelOptions.map((m) => {
const Icon = m.icon;
return (
<button
key={m.id}
type="button"
onClick={() => {
onModelSelect(m);
setShowModelMenu(false);
}}
className={cn(
'flex w-full items-center gap-2 px-3 py-2 text-xs transition-colors hover:bg-muted',
m.id === selectedModel.id
? 'text-primary'
: 'text-foreground',
)}
>
<Icon className="h-3 w-3" />
{m.label}
</button>
);
})}
</div>
)}
</div>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Refresh"
aria-label="Refresh"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
Comment on lines +87 to +94
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Refresh is a no-op clickable control.

Line 90-92 presents an actionable refresh button, but there is no handler. This is confusing UX unless intentionally disabled.

Suggested fix
 type AiPanelHeaderProps = {
   onClose: () => void;
+  onRefresh?: () => void;
   modelOptions: ModelOption[];
   selectedModel: ModelOption;
   onModelSelect: (model: ModelOption) => void;
 };
 
 export function AiPanelHeader({
   onClose,
+  onRefresh,
   modelOptions,
   selectedModel,
   onModelSelect,
 }: AiPanelHeaderProps) {
@@
         <button
           type="button"
+          onClick={onRefresh}
+          disabled={!onRefresh}
           className="flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
           title="Refresh"
           aria-label="Refresh"
         >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/epics/src/common/ai-panel/ai-panel-header.tsx` around lines 87 - 94,
The Refresh button in ai-panel-header.tsx is an interactive control with no
handler (the <button> containing <RefreshCw />), so either wire it to the
component's refresh logic or make it non-interactive; add an onClick prop that
calls the existing refresh/refreshState function or a passed-in prop like
onRefresh (or if none exists, expose a new prop and call the parent handler),
ensure accessibility by keeping aria-label and optionally add disabled when
refresh is unavailable, or replace the element with a non-button if it should be
purely decorative.

<button
type="button"
onClick={onClose}
className="flex h-7 w-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Hide AI panel"
aria-label="Close panel"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
Loading