feat: add /new slash command to create chat sessions
Introduces a `/new` slash command that lets users create a fresh chat session for the current agent without leaving the chat input. - Add `createNewSession` to `useGateway` that calls `sessions.create` on the gateway (with a client-side fallback key when the RPC is unavailable). - Register `/new` in the slash-command menu with i18n descriptions across all 8 supported languages. - Wire `onNewSession` through `Chat` → `ChatInput` so typing `/new` triggers session creation. - Add `extractAgentIdFromKey` and `formatAgentId` helpers to `sessionName.ts` to derive a human-readable agent name from the session key (e.g. `agent:my-cool-bot:…` → "My Cool Bot"). - Use the new helpers in `Header` and `App` to show per-session agent names, especially for sub-agent sessions where the gateway-level identity differs from the session agent. Made-with: Cursor
This commit is contained in:
@@ -18,8 +18,10 @@ interface Props {
|
||||
status: ConnectionStatus;
|
||||
sessionKey?: string;
|
||||
onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void;
|
||||
onNewSession?: () => Promise<void>;
|
||||
onAbort: () => void;
|
||||
agentAvatarUrl?: string;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
function isNoReply(msg: ChatMessage): boolean {
|
||||
@@ -70,7 +72,7 @@ function getDateKey(ts: number): string {
|
||||
/** Threshold in pixels — if the user is within this distance of the bottom, auto-scroll */
|
||||
const SCROLL_THRESHOLD = 150;
|
||||
|
||||
export function Chat({ messages, isGenerating, isLoadingHistory, status, sessionKey, onSend, onAbort, agentAvatarUrl }: Props) {
|
||||
export function Chat({ messages, isGenerating, isLoadingHistory, status, sessionKey, onSend, onNewSession, onAbort, agentAvatarUrl, agentName }: Props) {
|
||||
const t = useT();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -206,6 +208,8 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
||||
}, [messages]);
|
||||
|
||||
const showTyping = isGenerating && !hasStreamedText(messages);
|
||||
const sessionAgentId = sessionKey?.match(/^agent:([^:]+):/)?.[1];
|
||||
const welcomeTitle = agentName || sessionAgentId || t('chat.welcome');
|
||||
|
||||
const { globalState, collapseAll, expandAll } = useToolCollapse();
|
||||
const { toggle: toggleBookmark, isBookmarked, getForSession: getBookmarks } = useBookmarks();
|
||||
@@ -291,7 +295,7 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
||||
<Bot className="h-8 w-8 text-pc-accent-light" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-lg text-pc-text font-semibold">{t('chat.welcome')}</div>
|
||||
<div className="text-lg text-pc-text font-semibold">{welcomeTitle}</div>
|
||||
<div className="text-sm mt-1 text-pc-text-muted">{t('chat.welcomeSub')}</div>
|
||||
<div className="mt-8 flex flex-col items-center gap-3 max-w-md w-full">
|
||||
<div className="flex items-center gap-1.5 text-xs text-pc-text-faint">
|
||||
@@ -422,7 +426,7 @@ export function Chat({ messages, isGenerating, isLoadingHistory, status, session
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChatInput onSend={handleSend} onAbort={onAbort} isGenerating={isGenerating} disabled={status !== 'connected'} sessionKey={sessionKey} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} />
|
||||
<ChatInput onSend={handleSend} onNewSession={onNewSession} onAbort={onAbort} isGenerating={isGenerating} disabled={status !== 'connected'} sessionKey={sessionKey} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ReplyContext {
|
||||
|
||||
interface Props {
|
||||
onSend: (text: string, attachments?: Array<{ mimeType: string; fileName: string; content: string }>) => void;
|
||||
onNewSession?: () => Promise<void>;
|
||||
onAbort: () => void;
|
||||
isGenerating: boolean;
|
||||
disabled: boolean;
|
||||
@@ -94,7 +95,7 @@ function formatSize(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey, replyTo, onCancelReply }: Props) {
|
||||
export function ChatInput({ onSend, onNewSession, onAbort, isGenerating, disabled, sessionKey, replyTo, onCancelReply }: Props) {
|
||||
const t = useT();
|
||||
const { sendOnEnter, toggle: toggleSendShortcut } = useSendShortcut();
|
||||
const [text, setText] = useState('');
|
||||
@@ -177,6 +178,17 @@ export function ChatInput({ onSend, onAbort, isGenerating, disabled, sessionKey,
|
||||
const handleSubmit = () => {
|
||||
const trimmed = text.trim();
|
||||
if ((!trimmed && files.length === 0) || disabled) return;
|
||||
|
||||
if ((trimmed === '/new' || trimmed.startsWith('/new ')) && onNewSession) {
|
||||
void onNewSession();
|
||||
setText('');
|
||||
setFiles([]);
|
||||
setShowSlash(false);
|
||||
onCancelReply?.();
|
||||
if (sessionKey) draftsRef.current.delete(sessionKey);
|
||||
return;
|
||||
}
|
||||
|
||||
const attachments = files.length > 0 ? files.map(f => ({
|
||||
mimeType: f.mimeType,
|
||||
fileName: f.file.name,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ConnectionStatus, Session, ChatMessage } from '../types';
|
||||
import { useT } from '../hooks/useLocale';
|
||||
import { SettingsModal } from './SettingsModal';
|
||||
import { copyToClipboard } from '../lib/clipboard';
|
||||
import { sessionDisplayName } from '../lib/sessionName';
|
||||
import { sessionDisplayName, extractAgentIdFromKey, formatAgentId } from '../lib/sessionName';
|
||||
import { messagesToMarkdown, downloadFile } from '../lib/exportChat';
|
||||
|
||||
interface Props {
|
||||
@@ -24,6 +24,8 @@ interface Props {
|
||||
export function Header({ status, sessionKey, onToggleSidebar, activeSessionData, onLogout, soundEnabled, onToggleSound, messages, agentAvatarUrl, agentName, onCompact }: Props) {
|
||||
const t = useT();
|
||||
const sessionLabel = activeSessionData ? sessionDisplayName(activeSessionData) : (sessionKey.split(':').pop() || sessionKey);
|
||||
const sessionAgentId = activeSessionData?.agentId || extractAgentIdFromKey(sessionKey);
|
||||
const headerAgentName = agentName || (sessionAgentId && formatAgentId(sessionAgentId)) || t('header.title');
|
||||
const [showSessionInfo, setShowSessionInfo] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const sessionInfoRef = useRef<HTMLDivElement>(null);
|
||||
@@ -64,7 +66,7 @@ export function Header({ status, sessionKey, onToggleSidebar, activeSessionData,
|
||||
<img src={agentAvatarUrl || '/logo.png'} alt="PinchChat" className="h-9 w-9 rounded-2xl object-cover" onError={(e) => { const img = e.target as HTMLImageElement; if (img.src !== window.location.origin + '/logo.png') { img.src = '/logo.png'; } else { img.style.display = 'none'; } }} />
|
||||
<button className="min-w-0 text-left group" onClick={() => setShowSessionInfo(v => !v)} title={t('header.sessionInfo')} aria-label={t('header.sessionInfo')}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-pc-text text-sm tracking-wide">{agentName || t('header.title')}</span>
|
||||
<span className="font-semibold text-pc-text text-sm tracking-wide">{headerAgentName}</span>
|
||||
<Sparkles className="h-3.5 w-3.5 text-pc-accent-light/60" />
|
||||
</div>
|
||||
<span className="text-xs text-pc-text-muted truncate flex items-center gap-1.5">
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface SlashCommand {
|
||||
}
|
||||
|
||||
const COMMANDS: SlashCommand[] = [
|
||||
{ command: '/new', descKey: 'slash.new' },
|
||||
{ command: '/status', descKey: 'slash.status' },
|
||||
{ command: '/reasoning', args: 'on|off|stream', descKey: 'slash.reasoning' },
|
||||
{ command: '/verbose', args: 'on|off', descKey: 'slash.verbose' },
|
||||
|
||||
Reference in New Issue
Block a user