Skip to content
Draft
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
38 changes: 38 additions & 0 deletions src/components/ChatMessagesPanel/ChatAlerts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import React from 'react';
import { AlertTriangle } from 'lucide-react';
import { Button } from '../ui/Button';
import { Icon } from '../ui/Icon';

interface ChatAlertsProps {
chatError: string | null;
isServerConnected: boolean;
onClose?: () => void;
}

export const ChatAlerts: React.FC<ChatAlertsProps> = ({
chatError,
isServerConnected,
onClose,
}) => (
<>
{chatError && (
<div className="py-sm px-md bg-danger/10 border border-danger rounded-sm text-danger text-sm shrink-0">
{chatError}
</div>
)}

{!isServerConnected && (
<div className="flex items-center justify-between gap-md py-sm px-md bg-[var(--color-warning-alpha,rgba(255,193,7,0.1))] border border-[var(--color-warning,#ffc107)] rounded-sm text-[var(--color-warning-text,#856404)] text-sm shrink-0">
<span className="inline-flex items-center gap-2">
<Icon icon={AlertTriangle} size={16} />
Server not running — Chat is read-only
</span>
{onClose && (
<Button variant="secondary" size="sm" onClick={onClose}>
Close
</Button>
)}
</div>
)}
</>
);
110 changes: 110 additions & 0 deletions src/components/ChatMessagesPanel/ChatComposer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React from 'react';
import {
ComposerPrimitive,
useComposerRuntime,
} from '@assistant-ui/react';
import { Button } from '../ui/Button';
import { DeepResearchToggle } from '../DeepResearch';
import type { UseDeepResearchReturn } from '../../hooks/useDeepResearch';

interface ChatComposerProps {
isServerConnected: boolean;
isThreadRunning: boolean;
isDeepResearchEnabled: boolean;
toggleDeepResearch: () => void;
deepResearch: Pick<UseDeepResearchReturn, 'isRunning' | 'requestWrapUp' | 'state'>;
stopDeepResearch: () => void;
onDeepResearchSubmit: (query: string) => void;
onStopGeneration: () => void;
}

export const ChatComposer: React.FC<ChatComposerProps> = ({
isServerConnected,
isThreadRunning,
isDeepResearchEnabled,
toggleDeepResearch,
deepResearch,
stopDeepResearch,
onDeepResearchSubmit,
onStopGeneration,
}) => {
const composerRuntime = useComposerRuntime({ optional: true });

return (
<div className="border-t border-border p-md shrink-0">
{isThreadRunning && !deepResearch.isRunning && (
<div className="text-sm text-primary mb-sm animate-research-pulse">Assistant is thinking…</div>
)}
{deepResearch.isRunning && (
<div className="text-sm text-primary mb-sm animate-research-pulse">Researching… This may take a few minutes.</div>
)}
<ComposerPrimitive.Root className="flex gap-sm items-end">
<ComposerPrimitive.Input
className="flex-1 py-sm px-md border border-border rounded-base bg-surface text-text text-sm font-[inherit] resize-none min-h-[40px] max-h-[150px] focus:outline-none focus:border-primary disabled:opacity-50 disabled:cursor-not-allowed"
placeholder={
isServerConnected
? isDeepResearchEnabled
? 'Ask a research question (Deep Research mode)'
: 'Type your message. Shift + Enter for newline'
: 'Server not connected'
}
disabled={!isServerConnected || deepResearch.isRunning}
/>
<div className="flex gap-sm shrink-0">
<DeepResearchToggle
isEnabled={isDeepResearchEnabled}
onToggle={toggleDeepResearch}
isRunning={deepResearch.isRunning}
onStop={stopDeepResearch}
onWrapUp={deepResearch.requestWrapUp}
researchPhase={deepResearch.state?.phase}
disabled={!isServerConnected || isThreadRunning}
disabledReason={
!isServerConnected
? 'Server not connected'
: isThreadRunning
? 'Wait for current response'
: undefined
}
/>
{isThreadRunning && !deepResearch.isRunning && (
<Button
variant="danger"
size="sm"
onClick={onStopGeneration}
title="Stop generation"
>
Stop
</Button>
)}
{isDeepResearchEnabled ? (
<Button
variant="primary"
size="sm"
disabled={!isServerConnected || deepResearch.isRunning}
onClick={() => {
if (!composerRuntime) return;
const text = composerRuntime.getState().text.trim();
if (!text) return;
composerRuntime.setText('');
onDeepResearchSubmit(text);
}}
>
Research ↵
</Button>
) : (
<ComposerPrimitive.Send asChild>
<Button
variant="primary"
size="sm"
disabled={!isServerConnected}
>
Send ↵
</Button>
</ComposerPrimitive.Send>
)}
</div>
</ComposerPrimitive.Root>
</div>
);
};
123 changes: 123 additions & 0 deletions src/components/ChatMessagesPanel/ChatHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import React from 'react';
import { Download, Mic, MicOff, Pencil, RotateCcw, Sparkles } from 'lucide-react';
import { Button } from '../ui/Button';
import { Icon } from '../ui/Icon';
import { Input } from '../ui/Input';
import { ToolsPopover } from '../ToolsPopover';
import { ToolSupportIndicator } from '../ToolSupportIndicator';
import { cn } from '../../utils/cn';
import { getToolRegistry } from '../../services/tools';
import type { UseVoiceModeReturn } from '../../hooks/useVoiceMode';

interface ChatHeaderProps {
title: string | undefined;
isRenaming: boolean;
titleDraft: string;
setTitleDraft: (value: string) => void;
startRenaming: () => void;
commitRename: () => void;
cancelRenaming: () => void;
isGeneratingTitle: boolean;
generateTitle: () => void;
isThreadRunning: boolean;
activeConversationId: number | null;
serverPort: number;
supportsToolCalls: boolean | null;
toolFormat: string | null | undefined;
voice: UseVoiceModeReturn | undefined;
onClearConversation: () => void;
onExportConversation: () => void;
}

export const ChatHeader: React.FC<ChatHeaderProps> = ({
title,
isRenaming,
titleDraft,
setTitleDraft,
startRenaming,
commitRename,
cancelRenaming,
isGeneratingTitle,
generateTitle,
isThreadRunning,
activeConversationId,
serverPort,
supportsToolCalls,
toolFormat,
voice,
onClearConversation,
onExportConversation,
}) => (
<div className="p-base border-b border-border bg-background shrink-0 flex flex-wrap justify-between items-center gap-md phone:flex-nowrap">
<div className="flex items-center gap-sm min-w-0 basis-full phone:basis-auto phone:flex-1">
{isRenaming ? (
<Input
className="text-lg font-semibold bg-background border border-primary rounded-sm py-xs px-sm text-text min-w-[150px]"
value={titleDraft}
autoFocus
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitRename}
onKeyDown={(e) => {
if (e.key === 'Enter') commitRename();
else if (e.key === 'Escape') cancelRenaming();
}}
/>
) : (
<h2 className="text-lg font-semibold m-0 overflow-hidden text-ellipsis whitespace-nowrap">{title || 'New Chat'}</h2>
)}
<Button variant="ghost" size="sm" title="Rename conversation" onClick={startRenaming} iconOnly>
<Icon icon={Pencil} size={14} />
</Button>
<Button
variant="ghost"
size="sm"
className={cn(isGeneratingTitle && 'pointer-events-none')}
title={
!activeConversationId
? 'No active conversation'
: !serverPort
? 'Start a server to generate titles'
: 'Generate title with AI'
}
onClick={() => generateTitle()}
disabled={!activeConversationId || !serverPort || isGeneratingTitle || isThreadRunning}
iconOnly
>
{isGeneratingTitle ? (
<span className="inline-block w-[14px] h-[14px] border-2 border-text-muted border-t-primary rounded-full animate-spin-360" aria-label="Generating title…" />
) : (
<Icon icon={Sparkles} size={14} />
)}
</Button>
<span className={cn('text-xs py-xs px-sm rounded-full bg-background text-text-muted shrink-0', isThreadRunning && 'bg-primary/10 text-primary animate-research-pulse')}>
{isThreadRunning ? 'Responding…' : 'Idle'}
</span>
<ToolSupportIndicator
supports={supportsToolCalls}
hasToolsConfigured={getToolRegistry().getEnabledDefinitions().length > 0}
toolFormat={toolFormat}
/>
</div>
<div className="flex gap-sm shrink-0">
<ToolsPopover />
{voice?.isSupported && (
<Button
variant="ghost"
size="sm"
className={cn(voice.isActive && 'text-error')}
onClick={() => voice.isActive ? voice.stop() : voice.start()}
title={voice.isActive ? 'Stop voice mode' : 'Start voice mode'}
iconOnly
>
<Icon icon={voice.isActive ? MicOff : Mic} size={14} />
</Button>
)}
<Button variant="ghost" size="sm" onClick={onClearConversation} title="Restart conversation" iconOnly>
<Icon icon={RotateCcw} size={14} />
</Button>
<Button variant="ghost" size="sm" onClick={onExportConversation} title="Export conversation" iconOnly>
<Icon icon={Download} size={14} />
</Button>
</div>
</div>
);
Loading
Loading