Chat refactoring
This commit is contained in:
@@ -0,0 +1,46 @@
|
|||||||
|
## Authentication
|
||||||
|
|
||||||
|
src/apps/officer-web/Screens/Authentication/ForgotPassword/useResetPassword.ts
|
||||||
|
src/apps/officer-web/Screens/Authentication/VerifyScreen/useVerifyScreen.ts
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
src/apps/officer-web/Screens/Dashboard/Files/state/usePinnedFiles.ts
|
||||||
|
src/apps/officer-web/Screens/Dashboard/Files/state/useRecentFiles.ts
|
||||||
|
|
||||||
|
## Officer-web State
|
||||||
|
|
||||||
|
src/apps/officer-web/state/useChatGroups.ts
|
||||||
|
src/apps/officer-web/state/useChatSessions.ts
|
||||||
|
src/apps/officer-web/state/useInitialData.ts
|
||||||
|
src/apps/officer-web/state/useLandingPage.ts
|
||||||
|
src/apps/officer-web/state/useModels.ts
|
||||||
|
src/apps/officer-web/state/usePlans.ts
|
||||||
|
src/apps/officer-web/state/useProjectsState.ts
|
||||||
|
src/apps/officer-web/state/useRecentModels.ts
|
||||||
|
src/apps/officer-web/state/useResources.ts
|
||||||
|
src/apps/officer-web/state/useServerSettings.ts
|
||||||
|
src/apps/officer-web/state/useSettings.ts
|
||||||
|
src/apps/officer-web/state/useThemeSync.ts
|
||||||
|
src/apps/officer-web/state/useUserState.ts
|
||||||
|
src/apps/officer-web/state/useWorkspacesState.ts
|
||||||
|
|
||||||
|
## Chat (apps/Chat)
|
||||||
|
|
||||||
|
src/workspaces/apps/Chat/useChatSessions.ts
|
||||||
|
src/workspaces/apps/Chat/useChatSession.ts
|
||||||
|
src/workspaces/apps/Chat/usePi.ts
|
||||||
|
src/workspaces/apps/Chat/useSlashCommands.ts
|
||||||
|
|
||||||
|
## Other Workspaces
|
||||||
|
|
||||||
|
src/workspaces/apps/CodeEditor/useEditorState.ts
|
||||||
|
src/workspaces/apps/FileBrowser/useFiles.ts
|
||||||
|
src/workspaces/apps/FileBrowser/useTasks.ts
|
||||||
|
src/workspaces/components/DataTable/useFixedHeightPagination.ts
|
||||||
|
src/workspaces/components/ui/hooks/use-mobile.tsx
|
||||||
|
src/workspaces/components/ui/hooks/use-toast.ts
|
||||||
|
src/workspaces/components/ui/use-toast.ts
|
||||||
|
src/workspaces/i18n/src/useTranslation.ts
|
||||||
|
src/workspaces/injector/use-client.ts
|
||||||
|
Done!
|
||||||
@@ -10,8 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useVisiblePiModels } from '@/state/useModels';
|
import { useVisiblePiModels } from '@/state/useModels';
|
||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
import { usePi } from '@/Screens/Dashboard/Chat/usePi';
|
import { usePi, EmbeddableChat } from 'apps/Chat';
|
||||||
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
|
||||||
type CapabilitySummary = {
|
type CapabilitySummary = {
|
||||||
dirName: string;
|
dirName: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,35 +1,28 @@
|
|||||||
import { useRef, useEffect, useState } from 'react';
|
import { useRef, useEffect, useState } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router';
|
import { useNavigate, useLocation } from 'react-router';
|
||||||
import { useChatSessions } from '@/state/useChatSessions';
|
import { useChatSessions } from '@/state/useChatSessions';
|
||||||
import { useSlashCommands } from '@/state/useSlashCommands';
|
|
||||||
import { SessionBar } from 'apps/ChatHistory';
|
import { SessionBar } from 'apps/ChatHistory';
|
||||||
import type { ModelOption } from '@/state/useModels';
|
import { EmbeddableChat, type UsePiType, type Attachment } from 'apps/Chat';
|
||||||
import type { usePi } from './usePi';
|
|
||||||
import { EmbeddableChat, type Attachment } from './EmbeddableChat';
|
|
||||||
import { Card } from '@/components/Card';
|
import { Card } from '@/components/Card';
|
||||||
|
|
||||||
export type { Attachment };
|
export type { Attachment };
|
||||||
|
|
||||||
type ChatPanelProps = {
|
type ChatPanelProps = {
|
||||||
chat: ReturnType<typeof usePi>;
|
chat: UsePiType;
|
||||||
availableModels?: ModelOption[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ChatPanel = ({ chat, availableModels = [] }: ChatPanelProps) => {
|
export const ChatPanel = ({ chat }: ChatPanelProps) => {
|
||||||
const { isConnected, isGenerating, sessionId, setSelectedModel, sendPrompt } = chat;
|
const { isConnected, isGenerating, sessionId, setSelectedModel, sendPrompt } = chat;
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [commandFeedback, setCommandFeedback] = useState<string | null>(null);
|
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
const initialSentRef = useRef(false);
|
|
||||||
|
|
||||||
const { sessions, deleteSession } = useChatSessions();
|
const { sessions, deleteSession } = useChatSessions();
|
||||||
const slashCommands = useSlashCommands({ sessionId });
|
|
||||||
const sessionTitle = sessionId ? sessions.find((s) => s.id === sessionId)?.title : undefined;
|
const sessionTitle = sessionId ? sessions.find((s) => s.id === sessionId)?.title : undefined;
|
||||||
const listPath = '/chat';
|
const listPath = '/chat';
|
||||||
|
|
||||||
// Capture prefill input from location.state (one-time, before first render completes)
|
// Capture initial state from navigation
|
||||||
const locationState = location.state as {
|
const locationState = location.state as {
|
||||||
initialMessage?: string;
|
initialMessage?: string;
|
||||||
prefillInput?: string;
|
prefillInput?: string;
|
||||||
@@ -38,42 +31,30 @@ export const ChatPanel = ({ chat, availableModels = [] }: ChatPanelProps) => {
|
|||||||
attachmentIds?: string[];
|
attachmentIds?: string[];
|
||||||
images?: { filename: string; dataUrl: string }[];
|
images?: { filename: string; dataUrl: string }[];
|
||||||
} | null;
|
} | null;
|
||||||
const initialPrefill = useRef(locationState?.prefillInput ?? '');
|
|
||||||
|
const initialMessage = locationState?.initialMessage
|
||||||
const handleBeforeSend = async (text: string) => {
|
? {
|
||||||
if (text.startsWith('/')) {
|
text: locationState.initialMessage,
|
||||||
const result = await slashCommands.execute(text);
|
attachmentIds: locationState.attachmentIds,
|
||||||
if (result.handled) {
|
images: locationState.images,
|
||||||
setCommandFeedback(result.feedback);
|
cwd: locationState.cwd,
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
: undefined;
|
||||||
setCommandFeedback(null);
|
|
||||||
return false;
|
const defaultInput = locationState?.prefillInput ?? '';
|
||||||
};
|
const initialModel = locationState?.model ?? null;
|
||||||
|
|
||||||
// Auto-send initial message from Home launcher
|
// Clear location state after capturing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const state = location.state as typeof locationState;
|
if (locationState) {
|
||||||
if (!state || initialSentRef.current) return;
|
|
||||||
if (state.prefillInput) {
|
|
||||||
initialSentRef.current = true;
|
|
||||||
window.history.replaceState({}, '', location.pathname);
|
window.history.replaceState({}, '', location.pathname);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!state.initialMessage || !isConnected) return;
|
}, []);
|
||||||
initialSentRef.current = true;
|
|
||||||
if (state.model) setSelectedModel(state.model);
|
|
||||||
sendPrompt(state.initialMessage, state.attachmentIds, state.images, state.cwd);
|
|
||||||
// Clear the location state so refresh doesn't re-send
|
|
||||||
window.history.replaceState({}, '', location.pathname);
|
|
||||||
}, [isConnected, location.state]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={`flex flex-col overflow-hidden transition-all duration-200 rounded-none border-0 md:rounded-xl md:border-2 ${
|
className={`flex flex-col overflow-hidden transition-all duration-200 rounded-none border-0 md:rounded-xl md:border-2 ${fullscreen ? 'fixed inset-0 m-auto z-50 w-[90vw] h-[calc(90vh-4.5rem)]' : 'h-full'
|
||||||
fullscreen ? 'fixed inset-0 m-auto z-50 w-[90vw] h-[calc(90vh-4.5rem)]' : 'h-full'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<SessionBar
|
<SessionBar
|
||||||
listPath={listPath}
|
listPath={listPath}
|
||||||
@@ -90,11 +71,10 @@ export const ChatPanel = ({ chat, availableModels = [] }: ChatPanelProps) => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<EmbeddableChat
|
<EmbeddableChat
|
||||||
chat={chat}
|
sessionId={sessionId ?? undefined}
|
||||||
availableModels={availableModels}
|
initialModel={initialModel}
|
||||||
onBeforeSend={handleBeforeSend}
|
initialMessage={initialMessage}
|
||||||
commandFeedback={commandFeedback}
|
defaultInput={defaultInput}
|
||||||
defaultInput={initialPrefill.current}
|
|
||||||
className="flex-1 min-h-0"
|
className="flex-1 min-h-0"
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
||||||
import type { ModelOption } from '@/state/useModels';
|
|
||||||
import { useRecentModels } from '@/state/useRecentModels';
|
|
||||||
|
|
||||||
type OpenCodeModelPickerProps = {
|
|
||||||
models: ModelOption[];
|
|
||||||
selectedModel: string | null;
|
|
||||||
onSelect: (modelId: string) => void;
|
|
||||||
isConnected: boolean;
|
|
||||||
isGenerating: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OpenCodeModelPicker = ({
|
|
||||||
models,
|
|
||||||
selectedModel,
|
|
||||||
onSelect,
|
|
||||||
isConnected,
|
|
||||||
isGenerating,
|
|
||||||
}: OpenCodeModelPickerProps) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const { recents, addRecent } = useRecentModels();
|
|
||||||
|
|
||||||
const selected = models.find((m) => m.id === selectedModel);
|
|
||||||
|
|
||||||
const groupedByProvider = useMemo(() => {
|
|
||||||
const groups: Record<string, ModelOption[]> = {};
|
|
||||||
for (const m of models) {
|
|
||||||
const provider = m.provider ?? 'Other';
|
|
||||||
if (!groups[provider]) groups[provider] = [];
|
|
||||||
groups[provider].push(m);
|
|
||||||
}
|
|
||||||
return Object.entries(groups)
|
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
|
||||||
.map(([provider, items]) => ({
|
|
||||||
provider,
|
|
||||||
models: items.sort((a, b) => a.name.localeCompare(b.name)),
|
|
||||||
}));
|
|
||||||
}, [models]);
|
|
||||||
|
|
||||||
const handleSelect = (modelId: string) => {
|
|
||||||
const model = models.find((m) => m.id === modelId);
|
|
||||||
if (model) {
|
|
||||||
onSelect(model.id);
|
|
||||||
addRecent(model);
|
|
||||||
}
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded={open}
|
|
||||||
disabled={isGenerating || !isConnected}
|
|
||||||
className="h-auto border-0 bg-transparent p-0 text-xs text-duck-dark/50 shadow-none focus:ring-0 gap-1 cursor-pointer hover:bg-transparent hover:text-duck-dark/70"
|
|
||||||
>
|
|
||||||
{selected ? (
|
|
||||||
<>
|
|
||||||
{selected.name}
|
|
||||||
{selected.provider && <span className="hidden md:inline"> ({selected.provider})</span>}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'select model'
|
|
||||||
)}
|
|
||||||
<ChevronsUpDown className="h-3 w-3 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="z-[800] w-[320px] p-0" align="end" side="top">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Search models..." />
|
|
||||||
<CommandList className="max-h-[400px]">
|
|
||||||
<CommandEmpty>No models found.</CommandEmpty>
|
|
||||||
{recents.length > 0 && (
|
|
||||||
<CommandGroup heading="Recent">
|
|
||||||
{recents.map((m) => (
|
|
||||||
<CommandItem
|
|
||||||
key={`recent-${m.id}`}
|
|
||||||
value={`${m.name} ${m.provider ?? ''}`}
|
|
||||||
onSelect={() => handleSelect(m.id)}
|
|
||||||
>
|
|
||||||
<Check className={`mr-2 h-4 w-4 ${selectedModel === m.id ? 'opacity-100' : 'opacity-0'}`} />
|
|
||||||
<span className="truncate font-bold">{m.name}</span>
|
|
||||||
{m.provider && <span className="ml-1 text-xs text-muted-foreground">({m.provider})</span>}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
)}
|
|
||||||
{groupedByProvider.map(({ provider, models: providerModels }) => (
|
|
||||||
<CommandGroup key={provider} heading={provider}>
|
|
||||||
{providerModels.map((m) => (
|
|
||||||
<CommandItem key={m.id} value={`${m.name} ${m.provider ?? ''}`} onSelect={() => handleSelect(m.id)}>
|
|
||||||
<Check className={`mr-2 h-4 w-4 ${selectedModel === m.id ? 'opacity-100' : 'opacity-0'}`} />
|
|
||||||
<span className="truncate font-bold">{m.name}</span>
|
|
||||||
{m.provider && <span className="ml-1 text-xs text-muted-foreground">({m.provider})</span>}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
))}
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
export { ChatPanel } from './ChatPanel';
|
export { ChatPanel } from './ChatPanel';
|
||||||
export { EmbeddableChat, type Attachment } from './EmbeddableChat';
|
export { EmbeddableChat, usePi, ChatList } from 'apps/Chat';
|
||||||
export { InputArea } from './InputArea';
|
export type { Attachment } from 'apps/Chat';
|
||||||
export { Settings } from './Settings';
|
|
||||||
export { usePi } from './usePi';
|
|
||||||
export { ChatList } from './ChatList';
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
import { Trash2 } from 'lucide-react';
|
import { Trash2 } from 'lucide-react';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { useChatSessions } from '@/state/useChatSessions';
|
import { useChatSessions } from '@/state/useChatSessions';
|
||||||
import { useVisiblePiModels } from '@/state/useModels';
|
import { usePi, EmbeddableChat } from 'apps/Chat';
|
||||||
import { usePi } from '@/Screens/Dashboard/Chat/usePi';
|
|
||||||
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
|
||||||
|
|
||||||
export type SelectedSession = {
|
export type SelectedSession = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -66,12 +64,14 @@ type SessionChatProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function SessionChat({ sessionId, model }: SessionChatProps) {
|
function SessionChat({ sessionId, model }: SessionChatProps) {
|
||||||
const chat = usePi(sessionId, model, { replaceUrl: false });
|
|
||||||
const models = useVisiblePiModels();
|
|
||||||
const { sessions, deleteSession } = useChatSessions();
|
const { sessions, deleteSession } = useChatSessions();
|
||||||
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||||
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
|
const sessionTitle = sessions.find((s) => s.id === sessionId)?.title;
|
||||||
|
|
||||||
|
// We need connection status for the DetailBar, so we still call usePi here
|
||||||
|
// TODO: Consider moving DetailBar into EmbeddableChat or exposing status from it
|
||||||
|
const chat = usePi(sessionId, model, { replaceUrl: false });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<DetailBar
|
<DetailBar
|
||||||
@@ -84,7 +84,11 @@ function SessionChat({ sessionId, model }: SessionChatProps) {
|
|||||||
window.history.replaceState(null, '', '/chat');
|
window.history.replaceState(null, '', '/chat');
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<EmbeddableChat chat={chat} availableModels={models} className="flex-1 min-h-0" />
|
<EmbeddableChat
|
||||||
|
sessionId={sessionId}
|
||||||
|
initialModel={model ?? undefined}
|
||||||
|
className="flex-1 min-h-0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,30 +96,25 @@ function SessionChat({ sessionId, model }: SessionChatProps) {
|
|||||||
function NewChat() {
|
function NewChat() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const locationState = location.state as ChatLocationState;
|
const locationState = location.state as ChatLocationState;
|
||||||
const initialSentRef = useRef(false);
|
|
||||||
const chat = usePi();
|
|
||||||
const models = useVisiblePiModels();
|
|
||||||
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
const [, setSelected] = usePanelChannel<SelectedSession>(CHANNEL, null);
|
||||||
|
|
||||||
|
// We need connection status for DetailBar, so call usePi
|
||||||
|
const chat = usePi();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chat.sessionId) {
|
if (chat.sessionId) {
|
||||||
setSelected({ id: chat.sessionId, model: chat.model });
|
setSelected({ id: chat.sessionId, model: chat.model });
|
||||||
}
|
}
|
||||||
}, [chat.sessionId]);
|
}, [chat.sessionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
const initialMessage = locationState?.initialMessage
|
||||||
if (!locationState || initialSentRef.current || !chat.isConnected) return;
|
? {
|
||||||
if (locationState.prefillInput) {
|
text: locationState.initialMessage,
|
||||||
initialSentRef.current = true;
|
attachmentIds: locationState.attachmentIds,
|
||||||
window.history.replaceState({}, '', location.pathname);
|
images: locationState.images,
|
||||||
return;
|
cwd: locationState.cwd,
|
||||||
}
|
}
|
||||||
if (!locationState.initialMessage) return;
|
: undefined;
|
||||||
initialSentRef.current = true;
|
|
||||||
if (locationState.model) chat.setSelectedModel(locationState.model);
|
|
||||||
chat.sendPrompt(locationState.initialMessage, locationState.attachmentIds, locationState.images, locationState.cwd);
|
|
||||||
window.history.replaceState({}, '', location.pathname);
|
|
||||||
}, [chat.isConnected, location.state]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -126,8 +125,9 @@ function NewChat() {
|
|||||||
onDelete={undefined}
|
onDelete={undefined}
|
||||||
/>
|
/>
|
||||||
<EmbeddableChat
|
<EmbeddableChat
|
||||||
chat={chat}
|
sessionId={undefined}
|
||||||
availableModels={models}
|
initialModel={locationState?.model ?? undefined}
|
||||||
|
initialMessage={initialMessage}
|
||||||
defaultInput={locationState?.prefillInput ?? ''}
|
defaultInput={locationState?.prefillInput ?? ''}
|
||||||
cwd={locationState?.cwd}
|
cwd={locationState?.cwd}
|
||||||
className="flex-1 min-h-0"
|
className="flex-1 min-h-0"
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
|||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { cardStyle } from '@/components/Card';
|
import { cardStyle } from '@/components/Card';
|
||||||
import type { TaskInfo } from 'apps/Chat';
|
import type { TaskInfo } from 'apps/Chat';
|
||||||
import { usePi } from '@/Screens/Dashboard/Chat/usePi';
|
import { usePi, EmbeddableChat } from 'apps/Chat';
|
||||||
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
|
||||||
import { useVisiblePiModels } from '@/state/useModels';
|
import { useVisiblePiModels } from '@/state/useModels';
|
||||||
import { useSettings } from '@/state/useSettings';
|
import { useSettings } from '@/state/useSettings';
|
||||||
import type { TaskSummary } from 'apps/FileBrowser';
|
import type { TaskSummary } from 'apps/FileBrowser';
|
||||||
|
|||||||
@@ -1,357 +1,44 @@
|
|||||||
import { useState, useMemo, useRef, useEffect, type KeyboardEvent } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import {
|
|
||||||
Send,
|
|
||||||
ChevronDown,
|
|
||||||
Check,
|
|
||||||
Paperclip,
|
|
||||||
Link as LinkIcon,
|
|
||||||
Loader2,
|
|
||||||
X,
|
|
||||||
FileText,
|
|
||||||
Image,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { useClient } from 'hooks/useClient';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Widget } from 'widgets/Widget';
|
import { Widget } from 'widgets/Widget';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import { useSettings } from '@/state/useSettings';
|
import { useSettings } from '@/state/useSettings';
|
||||||
import { useVisiblePiModels, type ModelOption } from '@/state/useModels';
|
import { useVisiblePiModels } from '@/state/useModels';
|
||||||
import type { Attachment } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
import { ChatLauncher as ChatLauncherComponent } from 'apps/Chat';
|
||||||
|
|
||||||
const PROVIDER_DISPLAY: Record<string, string> = {
|
|
||||||
anthropic: 'Anthropic',
|
|
||||||
openai: 'OpenAI',
|
|
||||||
opencode: 'OpenCode Zen',
|
|
||||||
google: 'Google',
|
|
||||||
groq: 'Groq',
|
|
||||||
mistral: 'Mistral',
|
|
||||||
xai: 'xAI',
|
|
||||||
openrouter: 'OpenRouter',
|
|
||||||
huggingface: 'Hugging Face',
|
|
||||||
'github-copilot': 'GitHub Copilot',
|
|
||||||
minimax: 'MiniMax',
|
|
||||||
bedrock: 'Amazon Bedrock',
|
|
||||||
'google-vertex': 'Google Vertex AI',
|
|
||||||
'azure-openai': 'Azure OpenAI',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ChatLauncher = () => {
|
export const ChatLauncher = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const piModels = useVisiblePiModels();
|
const piModels = useVisiblePiModels();
|
||||||
|
|
||||||
const client = useClient();
|
|
||||||
const [model, setModel] = useState<string | null>(settings.chat.defaultModel);
|
const [model, setModel] = useState<string | null>(settings.chat.defaultModel);
|
||||||
const [input, setInput] = useState('');
|
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
|
||||||
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
|
|
||||||
const [urlInput, setUrlInput] = useState('');
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setModel(settings.chat.defaultModel);
|
setModel(settings.chat.defaultModel);
|
||||||
}, [settings.chat.defaultModel]);
|
}, [settings.chat.defaultModel]);
|
||||||
|
|
||||||
const providers = useMemo(
|
const handleSubmit = (data: {
|
||||||
() => [...new Set(piModels.map((m: ModelOption) => m.provider).filter(Boolean))] as string[],
|
prompt: string;
|
||||||
[piModels],
|
model: string | null;
|
||||||
);
|
attachmentIds?: string[];
|
||||||
|
images?: { filename: string; dataUrl: string }[];
|
||||||
const activeProvider = piModels.find((m: ModelOption) => m.id === model)?.provider ?? providers[0];
|
}) => {
|
||||||
const providerModels = piModels.filter((m: ModelOption) => m.provider === activeProvider);
|
|
||||||
|
|
||||||
const displayName = (provider: string) => PROVIDER_DISPLAY[provider] ?? provider;
|
|
||||||
|
|
||||||
const handleProviderClick = (provider: string) => {
|
|
||||||
const firstModel = piModels.find((m: ModelOption) => m.provider === provider);
|
|
||||||
if (firstModel) setModel(firstModel.id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAttachWebpage = async (url: string) => {
|
|
||||||
const idx = attachments.length;
|
|
||||||
setAttachments((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ type: 'webpage', url, title: '', content: '', attachmentId: '', loading: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await client.post<{ url: string; title: string; content: string; attachmentId: string }>('/scrape', {
|
|
||||||
url,
|
|
||||||
provider: 'pi-mono',
|
|
||||||
});
|
|
||||||
setAttachments((prev) =>
|
|
||||||
prev.map((a, i) =>
|
|
||||||
i === idx
|
|
||||||
? { ...a, title: res.title, content: res.content, attachmentId: res.attachmentId, loading: false }
|
|
||||||
: a,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
|
||||||
toast.error('Failed to scrape webpage');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAttachImage = async (file: File) => {
|
|
||||||
const idx = attachments.length;
|
|
||||||
setAttachments((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ type: 'image', filename: file.name, dataUrl: '', attachmentId: '', loading: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
formData.append('provider', 'pi-mono');
|
|
||||||
|
|
||||||
const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData);
|
|
||||||
setAttachments((prev) =>
|
|
||||||
prev.map((a, i) =>
|
|
||||||
i === idx ? { ...a, dataUrl: res.dataUrl, attachmentId: res.attachmentId, loading: false } : a,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
|
||||||
toast.error('Failed to upload image');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUrlSubmit = () => {
|
|
||||||
const url = urlInput.trim();
|
|
||||||
if (!url) return;
|
|
||||||
handleAttachWebpage(url);
|
|
||||||
setUrlInput('');
|
|
||||||
setUrlDialogOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
const text = input.trim();
|
|
||||||
if (!text) return;
|
|
||||||
|
|
||||||
let prompt = text;
|
|
||||||
const attachmentIds: string[] = [];
|
|
||||||
const images: { filename: string; dataUrl: string }[] = [];
|
|
||||||
for (const a of attachments) {
|
|
||||||
if (a.loading) continue;
|
|
||||||
if (a.type === 'webpage' && a.content) {
|
|
||||||
prompt = `[Attached webpage: ${a.url}]\n${a.content}\n\n${prompt}`;
|
|
||||||
} else if (a.type === 'image' && a.dataUrl) {
|
|
||||||
prompt = `[Attached image: ${a.filename}]\n\n${prompt}`;
|
|
||||||
images.push({ filename: a.filename, dataUrl: a.dataUrl });
|
|
||||||
}
|
|
||||||
attachmentIds.push(a.attachmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
navigate('/chat/new', {
|
navigate('/chat/new', {
|
||||||
state: {
|
state: {
|
||||||
initialMessage: prompt,
|
initialMessage: data.prompt,
|
||||||
model,
|
model: data.model,
|
||||||
attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,
|
attachmentIds: data.attachmentIds,
|
||||||
images: images.length > 0 ? images : undefined,
|
images: data.images,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
||||||
if (ev.key === 'Enter' && !ev.shiftKey) {
|
|
||||||
ev.preventDefault();
|
|
||||||
handleSubmit();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const textarea = textareaRef.current;
|
|
||||||
if (!textarea) return;
|
|
||||||
textarea.style.height = 'auto';
|
|
||||||
textarea.style.height = Math.min(textarea.scrollHeight, 160) + 'px';
|
|
||||||
}, [input]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Widget title="Start Chat">
|
||||||
<Widget title="Start Chat">
|
<ChatLauncherComponent
|
||||||
<div className="p-4 pb-2 pt-1">
|
availableModels={piModels}
|
||||||
{attachments.length > 0 && (
|
selectedModel={model}
|
||||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
onModelChange={setModel}
|
||||||
{attachments.map((a, i) => (
|
onSubmit={handleSubmit}
|
||||||
<span
|
/>
|
||||||
key={i}
|
</Widget>
|
||||||
className="relative inline-flex items-center gap-1 px-2 py-1 text-xs bg-duck-teal/10 text-duck-teal rounded-md max-w-[240px] group"
|
|
||||||
>
|
|
||||||
{a.loading ? (
|
|
||||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
|
||||||
) : a.type === 'image' && a.dataUrl ? (
|
|
||||||
<img src={a.dataUrl} alt={a.filename} className="h-8 w-8 shrink-0 rounded object-cover" />
|
|
||||||
) : a.type === 'image' ? (
|
|
||||||
<Image className="h-3 w-3 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<LinkIcon className="h-3 w-3 shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="truncate">
|
|
||||||
{a.type === 'image' ? a.filename : a.loading ? a.url : a.title || a.url}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))}
|
|
||||||
className="shrink-0 hover:text-duck-dark cursor-pointer"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shrink-0 h-10 w-10 flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<Paperclip className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent side="top" align="start" className="z-[600]">
|
|
||||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => imageInputRef.current?.click()}>
|
|
||||||
<Image className="mr-2 h-4 w-4" />
|
|
||||||
Image
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem className="cursor-pointer">
|
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
|
||||||
Text File
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem className="cursor-pointer">
|
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
|
||||||
PDF
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => setUrlDialogOpen(true)}>
|
|
||||||
<LinkIcon className="mr-2 h-4 w-4" />
|
|
||||||
Webpage URL
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<input
|
|
||||||
ref={imageInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(ev) => {
|
|
||||||
const file = ev.target.files?.[0];
|
|
||||||
if (file) handleAttachImage(file);
|
|
||||||
ev.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
ref={textareaRef}
|
|
||||||
value={input}
|
|
||||||
onChange={(ev) => setInput(ev.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onPaste={(ev) => {
|
|
||||||
const items = ev.clipboardData?.items;
|
|
||||||
if (!items) return;
|
|
||||||
for (const item of Array.from(items)) {
|
|
||||||
if (item.type.startsWith('image/')) {
|
|
||||||
ev.preventDefault();
|
|
||||||
const file = item.getAsFile();
|
|
||||||
if (file) handleAttachImage(file);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="What do you want to work on now?"
|
|
||||||
rows={1}
|
|
||||||
className="flex-1 resize-none bg-transparent px-2 py-2 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none text-lg"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={!input.trim()}
|
|
||||||
size="icon"
|
|
||||||
className="shrink-0 h-10 w-10 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Send className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-4 pb-3">
|
|
||||||
<div className="flex items-center gap-1 rounded-lg bg-duck-dark/5 p-1">
|
|
||||||
{providers.map((provider) => (
|
|
||||||
<button
|
|
||||||
key={provider}
|
|
||||||
onClick={() => handleProviderClick(provider)}
|
|
||||||
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
|
|
||||||
activeProvider === provider
|
|
||||||
? 'bg-background text-duck-dark shadow-sm'
|
|
||||||
: 'text-duck-dark/50 hover:text-duck-dark/70'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{displayName(provider)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{providerModels.length > 0 && (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button className="flex items-center gap-1 text-xs text-duck-dark/50 hover:text-duck-dark/70 cursor-pointer transition-colors">
|
|
||||||
{providerModels.find((m) => m.id === (model ?? providerModels[0]?.id))?.name ?? providerModels[0]?.name}
|
|
||||||
<ChevronDown className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="z-[600] max-h-64 overflow-y-auto">
|
|
||||||
{providerModels.map((m) => (
|
|
||||||
<DropdownMenuItem key={m.id} onClick={() => setModel(m.id)} className="cursor-pointer">
|
|
||||||
<Check
|
|
||||||
className={`mr-2 h-3 w-3 ${(model ?? providerModels[0]?.id) === m.id ? 'opacity-100' : 'opacity-0'}`}
|
|
||||||
/>
|
|
||||||
<span className="font-bold">{m.name}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Widget>
|
|
||||||
|
|
||||||
<Dialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen}>
|
|
||||||
<DialogContent className="sm:max-w-md z-[700]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Attach Webpage</DialogTitle>
|
|
||||||
<DialogDescription>Enter a URL to scrape and attach as context.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={urlInput}
|
|
||||||
onChange={(ev) => setUrlInput(ev.target.value)}
|
|
||||||
onKeyDown={(ev) => {
|
|
||||||
if (ev.key === 'Enter') {
|
|
||||||
ev.preventDefault();
|
|
||||||
handleUrlSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="https://example.com"
|
|
||||||
className="flex-1 rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleUrlSubmit}
|
|
||||||
disabled={!urlInput.trim()}
|
|
||||||
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
|
||||||
>
|
|
||||||
Attach
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { TerminalView } from 'apps/Terminal';
|
|||||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'apps/FileViewer';
|
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'apps/FileViewer';
|
||||||
import { usePi } from '../Chat/usePi';
|
import { usePi } from 'apps/Chat';
|
||||||
import { ChatPanel } from '../Chat/ChatPanel';
|
import { ChatPanel } from '../Chat/ChatPanel';
|
||||||
import { useVisiblePiModels } from '@/state/useModels';
|
import { useVisiblePiModels } from '@/state/useModels';
|
||||||
import { ChatHistoryApp as ChatHistory } from '../ChatHistory';
|
import { ChatHistoryApp as ChatHistory } from '../ChatHistory';
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { useChatSessions } from '@/state/useChatSessions';
|
|
||||||
|
|
||||||
export type SlashCommandResult = { handled: true; feedback: string } | { handled: false };
|
|
||||||
|
|
||||||
type UseSlashCommandsParams = {
|
|
||||||
sessionId: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSlashCommands = ({ sessionId }: UseSlashCommandsParams) => {
|
|
||||||
const { renameSession } = useChatSessions();
|
|
||||||
|
|
||||||
const execute = async (input: string): Promise<SlashCommandResult> => {
|
|
||||||
const trimmed = input.trim();
|
|
||||||
if (!trimmed.startsWith('/')) return { handled: false };
|
|
||||||
|
|
||||||
const spaceIndex = trimmed.indexOf(' ');
|
|
||||||
const command = spaceIndex === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIndex);
|
|
||||||
const args = spaceIndex === -1 ? '' : trimmed.slice(spaceIndex + 1).trim();
|
|
||||||
|
|
||||||
switch (command) {
|
|
||||||
case 'rename':
|
|
||||||
if (!sessionId || !args) return { handled: false };
|
|
||||||
await renameSession(sessionId, args);
|
|
||||||
return { handled: true, feedback: `Session renamed to "${args}"` };
|
|
||||||
default:
|
|
||||||
return { handled: false };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { execute };
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { Paperclip, Image, Link, FileText } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
|
type AttachButtonProps = {
|
||||||
|
onAttachImage: (file: File) => void;
|
||||||
|
onAttachWebpage: () => void;
|
||||||
|
size?: 'sm' | 'md';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AttachButton({ onAttachImage, onAttachWebpage, size = 'sm' }: AttachButtonProps) {
|
||||||
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const sizeClasses = size === 'md' ? 'h-10 w-10' : 'h-7 w-7 md:h-9 md:w-9';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`shrink-0 ${sizeClasses} flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer`}
|
||||||
|
>
|
||||||
|
<Paperclip className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent side="top" align="start" className="z-[800]">
|
||||||
|
<DropdownMenuItem className="cursor-pointer" onSelect={() => imageInputRef.current?.click()}>
|
||||||
|
<Image className="mr-2 h-4 w-4" />
|
||||||
|
Image
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="cursor-pointer">
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
Text File
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="cursor-pointer">
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
PDF
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="cursor-pointer" onSelect={onAttachWebpage}>
|
||||||
|
<Link className="mr-2 h-4 w-4" />
|
||||||
|
Webpage URL
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<input
|
||||||
|
ref={imageInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(ev) => {
|
||||||
|
const file = ev.target.files?.[0];
|
||||||
|
if (file) onAttachImage(file);
|
||||||
|
ev.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Loader2, Image, Link, X } from 'lucide-react';
|
||||||
|
import type { Attachment } from './types';
|
||||||
|
|
||||||
|
type AttachmentListProps = {
|
||||||
|
attachments: Attachment[];
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AttachmentList({ attachments, onRemove }: AttachmentListProps) {
|
||||||
|
if (attachments.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||||
|
{attachments.map((a, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className="relative inline-flex items-center gap-1 px-2 py-1 text-xs bg-duck-teal/10 text-duck-teal rounded-md max-w-[240px] group"
|
||||||
|
>
|
||||||
|
{a.loading ? (
|
||||||
|
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
||||||
|
) : a.type === 'image' && a.dataUrl ? (
|
||||||
|
<img src={a.dataUrl} alt={a.filename} className="h-8 w-8 shrink-0 rounded object-cover" />
|
||||||
|
) : a.type === 'image' ? (
|
||||||
|
<Image className="h-3 w-3 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Link className="h-3 w-3 shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">
|
||||||
|
{a.type === 'image' ? a.filename : a.loading ? 'Loading...' : a.title}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(i)}
|
||||||
|
className="shrink-0 hover:text-duck-dark cursor-pointer"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { useState, useMemo, useRef, useEffect, type KeyboardEvent } from 'react';
|
||||||
|
import { Send } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import type { ModelOption, Attachment } from './types';
|
||||||
|
import { ModelSelector } from './ModelSelector';
|
||||||
|
import { AttachmentList } from './AttachmentList';
|
||||||
|
import { AttachButton } from './AttachButton';
|
||||||
|
import { WebpageDialog } from './WebpageDialog';
|
||||||
|
|
||||||
|
type ChatLauncherProps = {
|
||||||
|
availableModels: ModelOption[];
|
||||||
|
selectedModel: string | null;
|
||||||
|
onModelChange: (modelId: string) => void;
|
||||||
|
onSubmit: (data: {
|
||||||
|
prompt: string;
|
||||||
|
model: string | null;
|
||||||
|
attachmentIds?: string[];
|
||||||
|
images?: { filename: string; dataUrl: string }[];
|
||||||
|
}) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChatLauncher({
|
||||||
|
availableModels,
|
||||||
|
selectedModel,
|
||||||
|
onModelChange,
|
||||||
|
onSubmit,
|
||||||
|
placeholder = 'What do you want to work on now?',
|
||||||
|
}: ChatLauncherProps) {
|
||||||
|
const client = useClient();
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
|
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
const handleAttachWebpage = async (url: string) => {
|
||||||
|
const idx = attachments.length;
|
||||||
|
setAttachments((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ type: 'webpage', url, title: '', content: '', attachmentId: '', loading: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await client.post<{ url: string; title: string; content: string; attachmentId: string }>('/scrape', {
|
||||||
|
url,
|
||||||
|
provider: 'pi-mono',
|
||||||
|
});
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.map((a, i) =>
|
||||||
|
i === idx
|
||||||
|
? { ...a, title: res.title, content: res.content, attachmentId: res.attachmentId, loading: false }
|
||||||
|
: a,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
toast.error('Failed to scrape webpage');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAttachImage = async (file: File) => {
|
||||||
|
const idx = attachments.length;
|
||||||
|
setAttachments((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ type: 'image', filename: file.name, dataUrl: '', attachmentId: '', loading: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('provider', 'pi-mono');
|
||||||
|
|
||||||
|
const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData);
|
||||||
|
setAttachments((prev) =>
|
||||||
|
prev.map((a, i) =>
|
||||||
|
i === idx ? { ...a, dataUrl: res.dataUrl, attachmentId: res.attachmentId, loading: false } : a,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
||||||
|
toast.error('Failed to upload image');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const text = input.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
let prompt = text;
|
||||||
|
const attachmentIds: string[] = [];
|
||||||
|
const images: { filename: string; dataUrl: string }[] = [];
|
||||||
|
|
||||||
|
for (const a of attachments) {
|
||||||
|
if (a.loading) continue;
|
||||||
|
if (a.type === 'webpage' && a.content) {
|
||||||
|
prompt = `[Attached webpage: ${a.url}]\n${a.content}\n\n${prompt}`;
|
||||||
|
} else if (a.type === 'image' && a.dataUrl) {
|
||||||
|
prompt = `[Attached image: ${a.filename}]\n\n${prompt}`;
|
||||||
|
images.push({ filename: a.filename, dataUrl: a.dataUrl });
|
||||||
|
}
|
||||||
|
attachmentIds.push(a.attachmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
prompt,
|
||||||
|
model: selectedModel,
|
||||||
|
attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,
|
||||||
|
images: images.length > 0 ? images : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setInput('');
|
||||||
|
setAttachments([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (ev: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (ev.key === 'Enter' && !ev.shiftKey) {
|
||||||
|
ev.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImagePaste = (file: File) => {
|
||||||
|
handleAttachImage(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-resize textarea
|
||||||
|
useEffect(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (!textarea) return;
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
textarea.style.height = Math.min(textarea.scrollHeight, 160) + 'px';
|
||||||
|
}, [input]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 pb-2 pt-1">
|
||||||
|
<AttachmentList attachments={attachments} onRemove={(i) => setAttachments((prev) => prev.filter((_, j) => j !== i))} />
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<AttachButton
|
||||||
|
size="md"
|
||||||
|
onAttachImage={handleAttachImage}
|
||||||
|
onAttachWebpage={() => setUrlDialogOpen(true)}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(ev) => setInput(ev.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onPaste={(ev) => {
|
||||||
|
const items = ev.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.type.startsWith('image/')) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) handleImagePaste(file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 resize-none bg-transparent px-2 py-2 text-duck-dark placeholder:text-duck-dark/30 focus:outline-none text-lg"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!input.trim()}
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 h-10 w-10 bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModelSelector
|
||||||
|
messages={[]}
|
||||||
|
availableModels={availableModels}
|
||||||
|
selectedModel={selectedModel}
|
||||||
|
onModelChange={onModelChange}
|
||||||
|
model={selectedModel}
|
||||||
|
isConnected={true}
|
||||||
|
isGenerating={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WebpageDialog
|
||||||
|
open={urlDialogOpen}
|
||||||
|
onOpenChange={setUrlDialogOpen}
|
||||||
|
onSubmit={handleAttachWebpage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+42
-18
@@ -2,20 +2,22 @@ import type { KeyboardEvent } from 'react';
|
|||||||
import { useRef, useEffect, useState } from 'react';
|
import { useRef, useEffect, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import type { ModelOption } from '@/state/useModels';
|
import { useVisiblePiModels } from '@/state/useModels';
|
||||||
import type { usePi } from './usePi';
|
import { usePi } from './usePi';
|
||||||
import { MessageList } from 'apps/Chat';
|
import { MessageList } from './MessageList';
|
||||||
import { InputArea } from './InputArea';
|
import { InputArea } from './InputArea';
|
||||||
|
import type { Attachment } from './types';
|
||||||
export type Attachment =
|
import { useSlashCommands } from './useSlashCommands';
|
||||||
| { type: 'webpage'; url: string; title: string; content: string; attachmentId: string; loading?: boolean }
|
|
||||||
| { type: 'image'; filename: string; dataUrl: string; attachmentId: string; loading?: boolean };
|
|
||||||
|
|
||||||
type EmbeddableChatProps = {
|
type EmbeddableChatProps = {
|
||||||
chat: ReturnType<typeof usePi>;
|
sessionId?: string;
|
||||||
availableModels?: ModelOption[];
|
initialModel?: string | null;
|
||||||
onBeforeSend?: (text: string) => boolean | Promise<boolean>;
|
initialMessage?: {
|
||||||
commandFeedback?: string | null;
|
text: string;
|
||||||
|
attachmentIds?: string[];
|
||||||
|
images?: { filename: string; dataUrl: string }[];
|
||||||
|
cwd?: { root?: string; path: string };
|
||||||
|
};
|
||||||
defaultInput?: string;
|
defaultInput?: string;
|
||||||
promptPrefix?: string;
|
promptPrefix?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -24,16 +26,17 @@ type EmbeddableChatProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const EmbeddableChat = ({
|
export const EmbeddableChat = ({
|
||||||
chat,
|
sessionId: initialSessionId,
|
||||||
availableModels = [],
|
initialModel,
|
||||||
onBeforeSend,
|
initialMessage,
|
||||||
commandFeedback = null,
|
|
||||||
defaultInput = '',
|
defaultInput = '',
|
||||||
promptPrefix,
|
promptPrefix,
|
||||||
className,
|
className,
|
||||||
cwd,
|
cwd,
|
||||||
autoSend = false,
|
autoSend = false,
|
||||||
}: EmbeddableChatProps) => {
|
}: EmbeddableChatProps) => {
|
||||||
|
const chat = usePi(initialSessionId, initialModel);
|
||||||
|
const availableModels = useVisiblePiModels();
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
streamingText,
|
streamingText,
|
||||||
@@ -51,11 +54,14 @@ export const EmbeddableChat = ({
|
|||||||
const [input, setInput] = useState(defaultInput);
|
const [input, setInput] = useState(defaultInput);
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||||
const [showJumpToBottom, setShowJumpToBottom] = useState(false);
|
const [showJumpToBottom, setShowJumpToBottom] = useState(false);
|
||||||
|
const [commandFeedback, setCommandFeedback] = useState<string | null>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
|
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null);
|
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||||
const userScrolledRef = useRef(false);
|
const userScrolledRef = useRef(false);
|
||||||
|
|
||||||
|
const slashCommandHandler = useSlashCommands({ sessionId });
|
||||||
|
|
||||||
const handleAttachWebpage = async (url: string) => {
|
const handleAttachWebpage = async (url: string) => {
|
||||||
const idx = attachments.length;
|
const idx = attachments.length;
|
||||||
setAttachments((prev) => [
|
setAttachments((prev) => [
|
||||||
@@ -115,14 +121,17 @@ export const EmbeddableChat = ({
|
|||||||
const text = input.trim();
|
const text = input.trim();
|
||||||
if (!text || isGenerating) return;
|
if (!text || isGenerating) return;
|
||||||
|
|
||||||
if (onBeforeSend) {
|
// Handle slash commands
|
||||||
const handled = await onBeforeSend(text);
|
if (text.startsWith('/')) {
|
||||||
if (handled) {
|
const result = await slashCommandHandler.execute(text);
|
||||||
|
if (result.handled) {
|
||||||
|
setCommandFeedback(result.feedback);
|
||||||
setInput('');
|
setInput('');
|
||||||
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
setCommandFeedback(null);
|
||||||
|
|
||||||
// Prepend metadata/attachment content to the prompt
|
// Prepend metadata/attachment content to the prompt
|
||||||
let prompt = promptPrefix ? `${promptPrefix}\n\n${text}` : text;
|
let prompt = promptPrefix ? `${promptPrefix}\n\n${text}` : text;
|
||||||
@@ -203,6 +212,21 @@ export const EmbeddableChat = ({
|
|||||||
textareaRef.current?.focus();
|
textareaRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Auto-send initial message when provided
|
||||||
|
const initialSentRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialMessage && isConnected && !initialSentRef.current) {
|
||||||
|
initialSentRef.current = true;
|
||||||
|
if (initialModel) setSelectedModel(initialModel);
|
||||||
|
sendPrompt(
|
||||||
|
initialMessage.text,
|
||||||
|
initialMessage.attachmentIds,
|
||||||
|
initialMessage.images,
|
||||||
|
initialMessage.cwd,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [initialMessage, isConnected]);
|
||||||
|
|
||||||
// Auto-send first message when autoSend is enabled
|
// Auto-send first message when autoSend is enabled
|
||||||
const autoSentRef = useRef(false);
|
const autoSentRef = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
+18
-120
@@ -1,19 +1,14 @@
|
|||||||
import type { KeyboardEvent, RefObject } from 'react';
|
import type { KeyboardEvent, RefObject } from 'react';
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { FileText, Image, Link, Loader2, Mic, Paperclip, Send, Square, X } from 'lucide-react';
|
import { Loader2, Mic, Send, Square } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import type { ModelOption } from '@/state/useModels';
|
import type { ModelOption } from '@/state/useModels';
|
||||||
import type { ChatMessage } from 'apps/Chat';
|
import type { ChatMessage, Attachment } from './types';
|
||||||
import type { Attachment } from './EmbeddableChat';
|
import { ModelSelector } from './ModelSelector';
|
||||||
import { Settings } from './Settings';
|
import { AttachmentList } from './AttachmentList';
|
||||||
|
import { AttachButton } from './AttachButton';
|
||||||
|
import { WebpageDialog } from './WebpageDialog';
|
||||||
|
|
||||||
const blobToWav = async (blob: Blob): Promise<Blob> => {
|
const blobToWav = async (blob: Blob): Promise<Blob> => {
|
||||||
const ctx = new AudioContext();
|
const ctx = new AudioContext();
|
||||||
@@ -93,21 +88,11 @@ export const InputArea = ({
|
|||||||
onRemoveAttachment,
|
onRemoveAttachment,
|
||||||
}: InputAreaProps) => {
|
}: InputAreaProps) => {
|
||||||
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
|
const [urlDialogOpen, setUrlDialogOpen] = useState(false);
|
||||||
const [urlInput, setUrlInput] = useState('');
|
|
||||||
const [recording, setRecording] = useState(false);
|
const [recording, setRecording] = useState(false);
|
||||||
const [transcribing, setTranscribing] = useState(false);
|
const [transcribing, setTranscribing] = useState(false);
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
const chunksRef = useRef<Blob[]>([]);
|
const chunksRef = useRef<Blob[]>([]);
|
||||||
|
|
||||||
const handleUrlSubmit = () => {
|
|
||||||
const url = urlInput.trim();
|
|
||||||
if (!url) return;
|
|
||||||
onAttachWebpage(url);
|
|
||||||
setUrlInput('');
|
|
||||||
setUrlDialogOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMicClick = async () => {
|
const handleMicClick = async () => {
|
||||||
if (recording) {
|
if (recording) {
|
||||||
const recorder = mediaRecorderRef.current;
|
const recorder = mediaRecorderRef.current;
|
||||||
@@ -187,64 +172,13 @@ export const InputArea = ({
|
|||||||
<div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div>
|
<div className="mb-2 px-3 py-1.5 text-xs text-duck-teal bg-duck-teal/10 rounded-md">{commandFeedback}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{attachments.length > 0 && (
|
<AttachmentList attachments={attachments} onRemove={onRemoveAttachment} />
|
||||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
|
||||||
{attachments.map((a, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className="relative inline-flex items-center gap-1 px-2 py-1 text-xs bg-duck-teal/10 text-duck-teal rounded-md max-w-[240px] group"
|
|
||||||
>
|
|
||||||
{a.loading ? (
|
|
||||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin" />
|
|
||||||
) : a.type === 'image' && a.dataUrl ? (
|
|
||||||
<img src={a.dataUrl} alt={a.filename} className="h-8 w-8 shrink-0 rounded object-cover" />
|
|
||||||
) : a.type === 'image' ? (
|
|
||||||
<Image className="h-3 w-3 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<Link className="h-3 w-3 shrink-0" />
|
|
||||||
)}
|
|
||||||
<span className="truncate">{a.type === 'image' ? a.filename : a.loading ? a.url : a.title || a.url}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onRemoveAttachment(i)}
|
|
||||||
className="shrink-0 hover:text-duck-dark cursor-pointer"
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-end gap-1 md:gap-2">
|
<div className="flex items-end gap-1 md:gap-2">
|
||||||
<DropdownMenu>
|
<AttachButton
|
||||||
<DropdownMenuTrigger asChild>
|
onAttachImage={onAttachImage}
|
||||||
<button
|
onAttachWebpage={() => setUrlDialogOpen(true)}
|
||||||
type="button"
|
/>
|
||||||
className="shrink-0 h-7 w-7 md:h-9 md:w-9 flex items-center justify-center rounded-lg text-duck-dark/40 hover:text-duck-dark/70 hover:bg-duck-dark/5 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<Paperclip className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent side="top" align="start" className="z-[800]">
|
|
||||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => imageInputRef.current?.click()}>
|
|
||||||
<Image className="mr-2 h-4 w-4" />
|
|
||||||
Image
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem className="cursor-pointer">
|
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
|
||||||
Text File
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem className="cursor-pointer">
|
|
||||||
<FileText className="mr-2 h-4 w-4" />
|
|
||||||
PDF
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem className="cursor-pointer" onSelect={() => setUrlDialogOpen(true)}>
|
|
||||||
<Link className="mr-2 h-4 w-4" />
|
|
||||||
Webpage URL
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={transcribing}
|
disabled={transcribing}
|
||||||
@@ -262,17 +196,7 @@ export const InputArea = ({
|
|||||||
<Mic className="h-4 w-4" />
|
<Mic className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<input
|
|
||||||
ref={imageInputRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
className="hidden"
|
|
||||||
onChange={(ev) => {
|
|
||||||
const file = ev.target.files?.[0];
|
|
||||||
if (file) onAttachImage(file);
|
|
||||||
ev.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={input}
|
value={input}
|
||||||
@@ -309,7 +233,7 @@ export const InputArea = ({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Settings
|
<ModelSelector
|
||||||
messages={messages}
|
messages={messages}
|
||||||
availableModels={availableModels}
|
availableModels={availableModels}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
@@ -319,37 +243,11 @@ export const InputArea = ({
|
|||||||
isGenerating={isGenerating}
|
isGenerating={isGenerating}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen}>
|
<WebpageDialog
|
||||||
<DialogContent className="sm:max-w-md z-[700]">
|
open={urlDialogOpen}
|
||||||
<DialogHeader>
|
onOpenChange={setUrlDialogOpen}
|
||||||
<DialogTitle>Attach Webpage</DialogTitle>
|
onSubmit={onAttachWebpage}
|
||||||
<DialogDescription>Enter a URL to scrape and attach as context.</DialogDescription>
|
/>
|
||||||
</DialogHeader>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={urlInput}
|
|
||||||
onChange={(ev) => setUrlInput(ev.target.value)}
|
|
||||||
onKeyDown={(ev) => {
|
|
||||||
if (ev.key === 'Enter') {
|
|
||||||
ev.preventDefault();
|
|
||||||
handleUrlSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="https://example.com"
|
|
||||||
className="flex-1 rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleUrlSubmit}
|
|
||||||
disabled={!urlInput.trim()}
|
|
||||||
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
|
||||||
>
|
|
||||||
Attach
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -3,7 +3,6 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
|||||||
import type { ChatMessage } from './types';
|
import type { ChatMessage } from './types';
|
||||||
import { MessageBubble, StreamingBubble } from './MessageBubble';
|
import { MessageBubble, StreamingBubble } from './MessageBubble';
|
||||||
|
|
||||||
const MESSAGE_HEIGHT = 100; // Estimated height per message bubble
|
|
||||||
const OVERSCAN = 5;
|
const OVERSCAN = 5;
|
||||||
|
|
||||||
export const MessageList = ({
|
export const MessageList = ({
|
||||||
@@ -28,8 +27,9 @@ export const MessageList = ({
|
|||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: messages.length,
|
count: messages.length,
|
||||||
getScrollElement: () => scrollViewportRef.current,
|
getScrollElement: () => scrollViewportRef.current,
|
||||||
estimateSize: () => MESSAGE_HEIGHT,
|
estimateSize: () => 150, // Initial estimate, will be measured
|
||||||
overscan: OVERSCAN,
|
overscan: OVERSCAN,
|
||||||
|
measureElement: (element) => element.getBoundingClientRect().height,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,6 +50,8 @@ export const MessageList = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={virtualRow.key}
|
key={virtualRow.key}
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
className="absolute top-0 left-0 w-full px-4"
|
className="absolute top-0 left-0 w-full px-4"
|
||||||
style={{
|
style={{
|
||||||
transform: `translateY(${virtualRow.start}px)`,
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
|||||||
+5
-5
@@ -1,7 +1,7 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import type { ModelOption } from '@/state/useModels';
|
import type { ModelOption } from '@/state/useModels';
|
||||||
import type { ChatMessage } from 'apps/Chat';
|
import type { ChatMessage } from './types';
|
||||||
|
|
||||||
const PROVIDER_DISPLAY: Record<string, string> = {
|
const PROVIDER_DISPLAY: Record<string, string> = {
|
||||||
anthropic: 'Anthropic',
|
anthropic: 'Anthropic',
|
||||||
@@ -20,7 +20,7 @@ const PROVIDER_DISPLAY: Record<string, string> = {
|
|||||||
'azure-openai': 'Azure OpenAI',
|
'azure-openai': 'Azure OpenAI',
|
||||||
};
|
};
|
||||||
|
|
||||||
type SettingsProps = {
|
type ModelSelectorProps = {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
availableModels: ModelOption[];
|
availableModels: ModelOption[];
|
||||||
selectedModel: string | null;
|
selectedModel: string | null;
|
||||||
@@ -30,7 +30,7 @@ type SettingsProps = {
|
|||||||
isGenerating: boolean;
|
isGenerating: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Settings = ({
|
export function ModelSelector({
|
||||||
messages,
|
messages,
|
||||||
availableModels,
|
availableModels,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
@@ -38,7 +38,7 @@ export const Settings = ({
|
|||||||
model,
|
model,
|
||||||
isConnected,
|
isConnected,
|
||||||
isGenerating,
|
isGenerating,
|
||||||
}: SettingsProps) => {
|
}: ModelSelectorProps) {
|
||||||
const providers = useMemo(
|
const providers = useMemo(
|
||||||
() => [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[],
|
() => [...new Set(availableModels.map((m) => m.provider).filter(Boolean))] as string[],
|
||||||
[availableModels],
|
[availableModels],
|
||||||
@@ -102,4 +102,4 @@ export const Settings = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
type WebpageDialogProps = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (url: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WebpageDialog({ open, onOpenChange, onSubmit }: WebpageDialogProps) {
|
||||||
|
const [urlInput, setUrlInput] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const url = urlInput.trim();
|
||||||
|
if (!url) return;
|
||||||
|
onSubmit(url);
|
||||||
|
setUrlInput('');
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md z-[700]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Attach Webpage</DialogTitle>
|
||||||
|
<DialogDescription>Enter a URL to scrape and attach as context.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={urlInput}
|
||||||
|
onChange={(ev) => setUrlInput(ev.target.value)}
|
||||||
|
onKeyDown={(ev) => {
|
||||||
|
if (ev.key === 'Enter') {
|
||||||
|
ev.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="https://example.com"
|
||||||
|
className="flex-1 rounded-lg border border-duck-dark/20 bg-background px-3 py-2 text-sm text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!urlInput.trim()}
|
||||||
|
className="bg-duck-teal hover:bg-duck-teal/90 cursor-pointer disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Attach
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,17 +2,17 @@ export { MessageList } from './MessageList';
|
|||||||
export { MessageBubble, StreamingBubble } from './MessageBubble';
|
export { MessageBubble, StreamingBubble } from './MessageBubble';
|
||||||
export { ToolActivity } from './ToolActivity';
|
export { ToolActivity } from './ToolActivity';
|
||||||
export { QuestionActivity } from './QuestionActivity';
|
export { QuestionActivity } from './QuestionActivity';
|
||||||
|
export { ModelSelector } from './ModelSelector';
|
||||||
|
export { InputArea } from './InputArea';
|
||||||
|
export { ChatLauncher } from './ChatLauncher';
|
||||||
|
export { AttachmentList } from './AttachmentList';
|
||||||
|
export { AttachButton } from './AttachButton';
|
||||||
|
export { WebpageDialog } from './WebpageDialog';
|
||||||
|
export { EmbeddableChat } from './EmbeddableChat';
|
||||||
|
export { usePi, type UsePiType } from './usePi';
|
||||||
|
export { ChatList } from './ChatList';
|
||||||
|
export { useSlashCommands } from './useSlashCommands';
|
||||||
|
export { useChatSessions, type UseChatSessionsType } from './useChatSessions';
|
||||||
|
export { useChatSession, type UseChatSessionType } from './useChatSession';
|
||||||
|
|
||||||
export type {
|
export * from './types';
|
||||||
ChatMessage,
|
|
||||||
SessionEntry,
|
|
||||||
GroupEntry,
|
|
||||||
ServerMessage,
|
|
||||||
Message,
|
|
||||||
MessageCost,
|
|
||||||
ModelOption,
|
|
||||||
TaskInfo,
|
|
||||||
LegacyChatMessage,
|
|
||||||
LegacySessionEntry,
|
|
||||||
LegacyServerMessage,
|
|
||||||
} from './types';
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
export type Attachment =
|
||||||
|
| { type: 'webpage'; url: string; title: string; content: string; attachmentId: string; loading?: boolean }
|
||||||
|
| { type: 'image'; filename: string; dataUrl: string; attachmentId: string; loading?: boolean };
|
||||||
|
|
||||||
export type MessageCost = {
|
export type MessageCost = {
|
||||||
inputTokens: number;
|
inputTokens: number;
|
||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
@@ -80,6 +84,12 @@ export type Message = {
|
|||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SlashCommand = {
|
||||||
|
command: string;
|
||||||
|
description?: string;
|
||||||
|
execute: (args: string, sessionId: string) => Promise<{ success: boolean; feedback: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
// Legacy type aliases for backward compatibility during migration
|
// Legacy type aliases for backward compatibility during migration
|
||||||
// TODO: Remove after Phase 9 cleanup
|
// TODO: Remove after Phase 9 cleanup
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { SessionEntry, ChatMessage, Message } from './types';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
type SessionWithMessages = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
model: string;
|
||||||
|
cwd: string;
|
||||||
|
groupSlug?: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
messageCount: number;
|
||||||
|
cost: {
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
totalUSD: number;
|
||||||
|
};
|
||||||
|
messages: Message[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseChatSessionParams = {
|
||||||
|
sessionId: string | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useChatSession({ sessionId }: UseChatSessionParams) {
|
||||||
|
const client = useClient();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: session, isLoading } = useQuery<SessionWithMessages | null>({
|
||||||
|
queryKey: ['PI_SESSION', sessionId],
|
||||||
|
enabled: !!sessionId,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!sessionId) return null;
|
||||||
|
const result = await client.get<{ session: SessionWithMessages }>(`/pi/sessions/${sessionId}`);
|
||||||
|
return result.session;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveMessages(messages: ChatMessage[]) {
|
||||||
|
if (!sessionId) return;
|
||||||
|
await client.put(`/pi/sessions/${sessionId}/messages`, messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rename(title: string) {
|
||||||
|
if (!sessionId) return;
|
||||||
|
await client.patch(`/pi/sessions/${sessionId}`, { title });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['PI_SESSION', sessionId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSession() {
|
||||||
|
if (!sessionId) return;
|
||||||
|
await client.delete(`/pi/sessions/${sessionId}`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
|
||||||
|
queryClient.removeQueries({ queryKey: ['PI_SESSION', sessionId] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveToGroup(groupSlug: string | null) {
|
||||||
|
if (!sessionId) return;
|
||||||
|
await client.post(`/pi/sessions/${sessionId}/move`, { groupSlug });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['PI_SESSION', sessionId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['PI_SESSIONS'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['PI_GROUPS'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
isLoading,
|
||||||
|
saveMessages,
|
||||||
|
rename,
|
||||||
|
delete: deleteSession,
|
||||||
|
moveToGroup,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseChatSessionType = ReturnType<typeof useChatSession>;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { SessionEntry } from './types';
|
||||||
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
type UseChatSessionsParams = {
|
||||||
|
cwd?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useChatSessions({ cwd }: UseChatSessionsParams = {}) {
|
||||||
|
const client = useClient();
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
|
const { data: sessions = [], isLoading } = useQuery<SessionEntry[]>({
|
||||||
|
queryKey: ['PI_SESSIONS', cwd],
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
queryFn: async () => {
|
||||||
|
const result = await client.post<{ sessions: SessionEntry[] }>('/pi/sessions', cwd ? { cwd } : {});
|
||||||
|
return result.sessions;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function searchSessions(query: string) {
|
||||||
|
return client.get<{ results: SessionEntry[] }>(`/pi/sessions/search?q=${encodeURIComponent(query)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessions,
|
||||||
|
isLoading,
|
||||||
|
searchSessions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseChatSessionsType = ReturnType<typeof useChatSessions>;
|
||||||
+3
-1
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||||
import { useChatSessions } from '@/state/useChatSessions';
|
import { useChatSessions } from '@/state/useChatSessions';
|
||||||
import type { ChatMessage, ServerMessage, TaskInfo, Message } from 'apps/Chat';
|
import type { ChatMessage, ServerMessage, TaskInfo, Message } from './types';
|
||||||
|
|
||||||
const SAVE_DEBOUNCE_MS = 1000;
|
const SAVE_DEBOUNCE_MS = 1000;
|
||||||
|
|
||||||
@@ -283,3 +283,5 @@ export function usePi(initialSessionId?: string, initialModel?: string | null, o
|
|||||||
stopGeneration,
|
stopGeneration,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UsePiType = ReturnType<typeof usePi>;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useChatSession } from './useChatSession';
|
||||||
|
|
||||||
|
export type SlashCommandResult = { handled: true; feedback: string } | { handled: false };
|
||||||
|
|
||||||
|
type UseSlashCommandsParams = {
|
||||||
|
sessionId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSlashCommands({ sessionId }: UseSlashCommandsParams) {
|
||||||
|
const { rename } = useChatSession({ sessionId });
|
||||||
|
|
||||||
|
const execute = async (input: string): Promise<SlashCommandResult> => {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (!trimmed.startsWith('/')) return { handled: false };
|
||||||
|
if (!sessionId) return { handled: false };
|
||||||
|
|
||||||
|
const spaceIndex = trimmed.indexOf(' ');
|
||||||
|
const commandName = spaceIndex === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIndex);
|
||||||
|
const args = spaceIndex === -1 ? '' : trimmed.slice(spaceIndex + 1).trim();
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (commandName) {
|
||||||
|
case 'rename': {
|
||||||
|
if (!args.trim()) {
|
||||||
|
return { handled: true, feedback: 'Usage: /rename <new name>' };
|
||||||
|
}
|
||||||
|
await rename(args);
|
||||||
|
return { handled: true, feedback: `Session renamed to "${args}"` };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'help': {
|
||||||
|
const helpText = [
|
||||||
|
'Available commands:',
|
||||||
|
' /rename <name> - Rename the current session',
|
||||||
|
' /help - Show this help message',
|
||||||
|
].join('\n');
|
||||||
|
return { handled: true, feedback: helpText };
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return { handled: false };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return { handled: true, feedback: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { execute };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user