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:
67
package-lock.json
generated
67
package-lock.json
generated
@@ -560,7 +560,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -577,7 +576,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -594,7 +592,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -611,7 +608,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -628,7 +624,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -645,7 +640,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -662,7 +656,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -679,7 +672,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -696,7 +688,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -713,7 +704,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -730,7 +720,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -747,7 +736,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -764,7 +752,6 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -781,7 +768,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -798,7 +784,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -815,7 +800,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -832,7 +816,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -849,7 +832,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -866,7 +848,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -883,7 +864,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -900,7 +880,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -917,7 +896,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -934,7 +912,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -951,7 +928,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -968,7 +944,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -985,7 +960,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1281,7 +1255,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1295,7 +1268,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1309,7 +1281,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1323,7 +1294,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1337,7 +1307,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1351,7 +1320,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1365,7 +1333,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1379,7 +1346,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1393,7 +1359,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1407,7 +1372,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1421,7 +1385,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1435,7 +1398,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1449,7 +1411,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1463,7 +1424,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1477,7 +1437,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1491,7 +1450,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1505,7 +1463,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1519,7 +1476,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1533,7 +1489,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1547,7 +1502,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1561,7 +1515,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1575,7 +1528,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1589,7 +1541,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1603,7 +1554,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1617,7 +1567,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2065,7 +2014,7 @@
|
||||
"version": "24.10.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz",
|
||||
"integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
@@ -2075,7 +2024,6 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -2959,7 +2907,6 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
@@ -3102,7 +3049,6 @@
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -3408,7 +3354,6 @@
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
@@ -3477,7 +3422,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -5284,7 +5228,6 @@
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -5456,14 +5399,12 @@
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -5476,7 +5417,6 @@
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -5746,7 +5686,6 @@
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
@@ -5994,7 +5933,6 @@
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
@@ -6161,7 +6099,7 @@
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
@@ -6338,7 +6276,6 @@
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
|
||||
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