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:
23
src/App.tsx
23
src/App.tsx
@@ -8,7 +8,7 @@ import { LoginScreen } from './components/LoginScreen';
|
||||
import { ConnectionBanner } from './components/ConnectionBanner';
|
||||
import { KeyboardShortcuts } from './components/KeyboardShortcuts';
|
||||
import { ToolCollapseProvider } from './contexts/ToolCollapseContext';
|
||||
import { sessionDisplayName } from './lib/sessionName';
|
||||
import { sessionDisplayName, extractAgentIdFromKey, formatAgentId } from './lib/sessionName';
|
||||
import { X } from 'lucide-react';
|
||||
import { useT } from './hooks/useLocale';
|
||||
import { useSwipeSidebar } from './hooks/useSwipeSidebar';
|
||||
@@ -29,7 +29,7 @@ function getSavedSplitRatio(): number {
|
||||
export default function App() {
|
||||
const {
|
||||
status, messages, sessions, activeSession, isGenerating, isLoadingHistory,
|
||||
sendMessage, abort, switchSession, deleteSession,
|
||||
sendMessage, abort, switchSession, deleteSession, createNewSession,
|
||||
authenticated, login, logout, connectError, isConnecting, agentIdentity,
|
||||
getClient, addEventListener,
|
||||
} = useGateway();
|
||||
@@ -40,6 +40,19 @@ export default function App() {
|
||||
const splitRatioRef = useRef(splitRatio);
|
||||
const secondary = useSecondarySession(getClient, addEventListener, splitSession);
|
||||
const t = useT();
|
||||
const resolveAgentDisplayName = useCallback((sessionKey: string | null | undefined): string | undefined => {
|
||||
if (!sessionKey) return agentIdentity?.name;
|
||||
const session = sessions.find((s) => s.key === sessionKey);
|
||||
const sessionAgentId = session?.agentId || extractAgentIdFromKey(sessionKey);
|
||||
const connectedAgentId = agentIdentity?.agentId;
|
||||
|
||||
// agent.identity.get is gateway-level (typically main agent), not per-session.
|
||||
// For sub-agent sessions, prefer the session agent id to avoid showing the main agent name.
|
||||
if (sessionAgentId && connectedAgentId && sessionAgentId !== connectedAgentId) {
|
||||
return formatAgentId(sessionAgentId) || sessionAgentId;
|
||||
}
|
||||
return agentIdentity?.name || (sessionAgentId && formatAgentId(sessionAgentId)) || sessionAgentId;
|
||||
}, [agentIdentity?.name, agentIdentity?.agentId, sessions]);
|
||||
const handleSplit = useCallback((key: string) => {
|
||||
setSplitSession(prev => prev === key ? null : key);
|
||||
}, []);
|
||||
@@ -181,10 +194,10 @@ export default function App() {
|
||||
<div ref={splitContainerRef} className="flex-1 flex min-w-0" aria-hidden={sidebarOpen ? true : undefined}>
|
||||
{/* Primary pane */}
|
||||
<main className="flex flex-col min-w-0" style={splitSession ? { width: `${splitRatio}%` } : { flex: 1 }} aria-label={t('app.mainChat')}>
|
||||
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} soundEnabled={soundEnabled} onToggleSound={toggleSound} messages={messages} agentAvatarUrl={agentIdentity?.avatar} agentName={agentIdentity?.name} onCompact={handleCompact} />
|
||||
<Header status={status} sessionKey={activeSession} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} activeSessionData={sessions.find(s => s.key === activeSession)} onLogout={logout} soundEnabled={soundEnabled} onToggleSound={toggleSound} messages={messages} agentAvatarUrl={agentIdentity?.avatar} agentName={resolveAgentDisplayName(activeSession)} onCompact={handleCompact} />
|
||||
<ConnectionBanner status={status} />
|
||||
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-pc-text-muted"><div className="animate-pulse text-sm">Loading…</div></div>}>
|
||||
<Chat messages={messages} isGenerating={isGenerating} isLoadingHistory={isLoadingHistory} status={status} sessionKey={activeSession} onSend={sendMessage} onAbort={abort} agentAvatarUrl={agentIdentity?.avatar} />
|
||||
<Chat messages={messages} isGenerating={isGenerating} isLoadingHistory={isLoadingHistory} status={status} sessionKey={activeSession} onSend={sendMessage} onNewSession={createNewSession} onAbort={abort} agentAvatarUrl={agentIdentity?.avatar} agentName={resolveAgentDisplayName(activeSession)} />
|
||||
</Suspense>
|
||||
</main>
|
||||
{/* Split divider + secondary pane */}
|
||||
@@ -212,7 +225,7 @@ export default function App() {
|
||||
</button>
|
||||
</div>
|
||||
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-pc-text-muted"><div className="animate-pulse text-sm">Loading…</div></div>}>
|
||||
<Chat messages={secondary.messages} isGenerating={secondary.isGenerating} isLoadingHistory={secondary.isLoadingHistory} status={status} sessionKey={splitSession} onSend={secondary.sendMessage} onAbort={secondary.abort} agentAvatarUrl={agentIdentity?.avatar} />
|
||||
<Chat messages={secondary.messages} isGenerating={secondary.isGenerating} isLoadingHistory={secondary.isLoadingHistory} status={status} sessionKey={splitSession} onSend={secondary.sendMessage} onNewSession={createNewSession} onAbort={secondary.abort} agentAvatarUrl={agentIdentity?.avatar} agentName={resolveAgentDisplayName(splitSession)} />
|
||||
</Suspense>
|
||||
</section>
|
||||
</>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getStoredCredentials, storeCredentials, clearCredentials, type AuthMode
|
||||
import { getOrCreateDeviceIdentity } from '../lib/deviceIdentity';
|
||||
import { isSystemEvent } from '../lib/systemEvent';
|
||||
import { getCachedMessages, setCachedMessages, mergeWithCache } from '../lib/messageCache';
|
||||
import { extractAgentIdFromKey } from '../lib/sessionName';
|
||||
import { extractText, extractThinking, type ChatPayloadMessage } from '../lib/messageExtract';
|
||||
import type { ChatMessage, MessageBlock, ConnectionStatus, Session, AgentIdentity } from '../types';
|
||||
|
||||
@@ -23,9 +24,12 @@ export function useGateway() {
|
||||
const messagesRef = useRef(messages);
|
||||
const activeSessionRef = useRef(activeSession);
|
||||
|
||||
const sessionsRef = useRef(sessions);
|
||||
|
||||
// Sync refs in an effect to avoid ref writes during render
|
||||
useEffect(() => { messagesRef.current = messages; }, [messages]);
|
||||
useEffect(() => { activeSessionRef.current = activeSession; }, [activeSession]);
|
||||
useEffect(() => { sessionsRef.current = sessions; }, [sessions]);
|
||||
const currentRunIdRef = useRef<string | null>(null);
|
||||
const [activeSessions, setActiveSessions] = useState<Set<string>>(new Set());
|
||||
const [unreadSessions, setUnreadSessions] = useState<Map<string, number>>(new Map());
|
||||
@@ -480,6 +484,48 @@ export function useGateway() {
|
||||
loadHistory(key);
|
||||
}, [loadHistory]);
|
||||
|
||||
const createNewSession = useCallback(async () => {
|
||||
const client = clientRef.current;
|
||||
if (!client) return;
|
||||
|
||||
const currentKey = activeSessionRef.current;
|
||||
const currentSession = sessionsRef.current.find((s) => s.key === currentKey);
|
||||
const targetAgentId = currentSession?.agentId || extractAgentIdFromKey(currentKey) || 'main';
|
||||
const targetChannel = currentSession?.channel || 'webchat';
|
||||
const expectedPrefix = `agent:${targetAgentId}:`;
|
||||
|
||||
const fallbackKey = `${expectedPrefix}webchat-${Date.now()}`;
|
||||
let nextKey = fallbackKey;
|
||||
|
||||
try {
|
||||
const res = await client.send('sessions.create', {
|
||||
channel: targetChannel,
|
||||
agentId: targetAgentId,
|
||||
}) as JsonPayload | undefined;
|
||||
const fromRoot = (typeof res?.key === 'string' && res.key)
|
||||
|| (typeof res?.sessionKey === 'string' && res.sessionKey)
|
||||
|| null;
|
||||
const nestedSession = (res?.session && typeof res.session === 'object') ? res.session as Record<string, unknown> : null;
|
||||
const fromNested = (nestedSession && typeof nestedSession.key === 'string' && nestedSession.key)
|
||||
|| (nestedSession && typeof nestedSession.sessionKey === 'string' && nestedSession.sessionKey)
|
||||
|| null;
|
||||
|
||||
const returnedKey = (fromRoot || fromNested) as string | null;
|
||||
if (returnedKey && returnedKey.startsWith(expectedPrefix)) {
|
||||
nextKey = returnedKey;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[createNewSession] sessions.create not supported, using fallback key', err);
|
||||
}
|
||||
|
||||
switchSession(nextKey);
|
||||
try {
|
||||
await loadSessions();
|
||||
} catch (err) {
|
||||
console.warn('[createNewSession] failed to refresh session list', err);
|
||||
}
|
||||
}, [switchSession, loadSessions]);
|
||||
|
||||
const login = useCallback((url: string, token: string, authMode: AuthMode = 'token', clientId?: string) => {
|
||||
setupClient(url, token, authMode, clientId);
|
||||
}, [setupClient]);
|
||||
@@ -541,7 +587,7 @@ export function useGateway() {
|
||||
|
||||
return {
|
||||
status, messages, sessions: enrichedSessions, activeSession, isGenerating, isLoadingHistory,
|
||||
sendMessage, abort, switchSession, loadSessions, deleteSession,
|
||||
sendMessage, abort, switchSession, createNewSession, loadSessions, deleteSession,
|
||||
authenticated, login, logout, connectError, isConnecting, agentIdentity,
|
||||
getClient, addEventListener,
|
||||
};
|
||||
|
||||
@@ -189,6 +189,7 @@ const en = {
|
||||
'slash.model': 'Switch model for this session',
|
||||
'slash.compact': 'Compact conversation context',
|
||||
'slash.reset': 'Reset the session',
|
||||
'slash.new': 'Create a new chat session',
|
||||
'slash.help': 'Show available commands',
|
||||
} as const;
|
||||
|
||||
@@ -358,6 +359,7 @@ const fr: Record<keyof typeof en, string> = {
|
||||
'slash.model': 'Changer de modèle pour cette session',
|
||||
'slash.compact': 'Compacter le contexte',
|
||||
'slash.reset': 'Réinitialiser la session',
|
||||
'slash.new': 'Créer une nouvelle session de chat',
|
||||
'slash.help': 'Afficher les commandes disponibles',
|
||||
};
|
||||
|
||||
@@ -527,6 +529,7 @@ const es: Record<keyof typeof en, string> = {
|
||||
'slash.model': 'Cambiar modelo para esta sesión',
|
||||
'slash.compact': 'Compactar contexto de conversación',
|
||||
'slash.reset': 'Reiniciar la sesión',
|
||||
'slash.new': 'Crear una nueva sesión de chat',
|
||||
'slash.help': 'Mostrar comandos disponibles',
|
||||
};
|
||||
|
||||
@@ -698,6 +701,7 @@ const de: Record<keyof typeof en, string> = {
|
||||
'slash.model': 'Modell für diese Sitzung wechseln',
|
||||
'slash.compact': 'Gesprächskontext kompaktieren',
|
||||
'slash.reset': 'Sitzung zurücksetzen',
|
||||
'slash.new': 'Neue Chat-Sitzung erstellen',
|
||||
'slash.help': 'Verfügbare Befehle anzeigen',
|
||||
};
|
||||
|
||||
@@ -867,6 +871,7 @@ const ja: Record<keyof typeof en, string> = {
|
||||
'slash.model': 'このセッションのモデルを変更',
|
||||
'slash.compact': '会話コンテキストをコンパクト化',
|
||||
'slash.reset': 'セッションをリセット',
|
||||
'slash.new': '新しいチャットセッションを作成',
|
||||
'slash.help': '利用可能なコマンドを表示',
|
||||
};
|
||||
|
||||
@@ -1036,6 +1041,7 @@ const pt: Record<keyof typeof en, string> = {
|
||||
'slash.model': 'Alterar modelo desta sessão',
|
||||
'slash.compact': 'Compactar contexto da conversa',
|
||||
'slash.reset': 'Redefinir sessão',
|
||||
'slash.new': 'Criar uma nova sessão de chat',
|
||||
'slash.help': 'Mostrar comandos disponíveis',
|
||||
};
|
||||
|
||||
@@ -1205,6 +1211,7 @@ const zh: Record<keyof typeof en, string> = {
|
||||
'slash.model': '切换当前会话模型',
|
||||
'slash.compact': '压缩会话上下文',
|
||||
'slash.reset': '重置会话',
|
||||
'slash.new': '创建新的聊天会话',
|
||||
'slash.help': '显示可用命令',
|
||||
};
|
||||
|
||||
@@ -1374,6 +1381,7 @@ const it: Record<keyof typeof en, string> = {
|
||||
'slash.model': 'Cambia modello per questa sessione',
|
||||
'slash.compact': 'Compatta il contesto della conversazione',
|
||||
'slash.reset': 'Reimposta sessione',
|
||||
'slash.new': 'Crea una nuova sessione chat',
|
||||
'slash.help': 'Mostra comandi disponibili',
|
||||
};
|
||||
|
||||
|
||||
@@ -47,3 +47,22 @@ function cleanSessionKey(key: string): string {
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
const AGENT_KEY_RE = /^agent:([^:]+):/;
|
||||
|
||||
export function extractAgentIdFromKey(key: string): string | undefined {
|
||||
return key.match(AGENT_KEY_RE)?.[1];
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-/i;
|
||||
|
||||
/**
|
||||
* Turn a raw agent ID like "my-cool_agent" into "My Cool Agent".
|
||||
* Returns undefined for UUIDs / hex-heavy IDs that aren't human-readable.
|
||||
*/
|
||||
export function formatAgentId(id: string): string | undefined {
|
||||
if (UUID_RE.test(id)) return undefined;
|
||||
return id
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user