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:
Yaro
2026-02-28 22:43:20 +02:00
parent a11cebdc55
commit 4e66d7c4bd
9 changed files with 119 additions and 77 deletions

View File

@@ -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>
</>

View File

@@ -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>
);
}

View File

@@ -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,

View File

@@ -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">

View File

@@ -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' },

View File

@@ -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,
};

View File

@@ -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',
};

View File

@@ -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());
}