chat window now displays session name instead of UUID, and added delete confirmation for session

This commit is contained in:
Storme-bit
2026-04-13 04:35:49 -07:00
parent 4fd7f9824b
commit 630ec22d8a
6 changed files with 400 additions and 309 deletions

View File

@@ -18,29 +18,63 @@ inference services. Served as static files by Caddy on Mini PC 2.
- `vite` + `@vitejs/plugin-react` — build tooling - `vite` + `@vitejs/plugin-react` — build tooling
## Build ## Build
```bash ```bash
cd packages/chat-client cd packages/chat-client
npm run build # outputs to dist/
npm run dev # local dev server on port 5173 npm run dev # local dev server on port 5173
npm run build # outputs to dist/ for production
``` ```
After building, copy `dist/` contents to `/srv/nexusai` on Mini PC 2 for Caddy to serve.
Vite bakes environment variables into the bundle at build time. The `.env` Vite bakes environment variables into the bundle at build time. The `.env`
file is only needed on the machine running the build, not where files are served. file is only needed on the machine running the build, not where files are served.
After building, copy `dist/` contents to `/srv/nexusai` on Mini PC 2 for Caddy to serve.
## Environment Variables ## Environment Variables
| Variable | Required | Default | Description | | Variable | Required | Default | Description |
|---|---|---|---| |---|---|---|---|
| VITE_ORCHESTRATION_URL | No | `''` (empty) | Orchestration base URL. Must be set to the HTTPS domain in production to avoid mixed content errors. | | VITE_ORCHESTRATION_URL | No | `''` (empty) | Orchestration base URL. Leave empty in dev (Vite proxy handles routing). Set to HTTPS domain for production builds. |
Production value: **Development:** leave `VITE_ORCHESTRATION_URL` unset — the Vite proxy routes
API requests directly to orchestration, bypassing Caddy and Authelia.
**Production build:** set before running `npm run build`:
``` ```
VITE_ORCHESTRATION_URL=https://nexus.jellystorm.com VITE_ORCHESTRATION_URL=https://nexus.jellystorm.com
``` ```
> Do not set `VITE_ORCHESTRATION_URL` to the HTTPS domain during local dev.
> Requests from `localhost:5173` to `nexus.jellystorm.com` will hit Authelia,
> which returns an HTML login page instead of JSON — causing `Unexpected token '<'`
> parse errors in `useModels` and `useSession`.
## Vite Dev Proxy
`vite.config.js` proxies API routes directly to the orchestration service
during local development, bypassing Caddy and Authelia entirely:
```js
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/models': 'http://192.168.0.205:4000',
'/sessions': 'http://192.168.0.205:4000',
'/chat': 'http://192.168.0.205:4000',
}
}
});
```
If new routes are added to the orchestration service, add them here too.
## Internal Structure ## Internal Structure
``` ```
src/ src/
├── api/ ├── api/
@@ -58,7 +92,7 @@ src/
│ ├── ChatWindow.jsx # Centre panel — message thread and input bar │ ├── ChatWindow.jsx # Centre panel — message thread and input bar
│ ├── MessageBubble.jsx # Individual message bubble (user or assistant) │ ├── MessageBubble.jsx # Individual message bubble (user or assistant)
│ ├── InfoPanel.jsx # Right panel — model selector and session metadata │ ├── InfoPanel.jsx # Right panel — model selector and session metadata
│ └── SessionModal.jsx # Modal dialog for session settings (rename) │ └── SessionModal.jsx # Modal dialog for session settings and delete confirmation
├── index.css # Global reset, CSS variables, utility classes ├── index.css # Global reset, CSS variables, utility classes
└── main.jsx # React entry point └── main.jsx # React entry point
``` ```
@@ -66,6 +100,7 @@ src/
## Layout ## Layout
Three-panel layout with collapsible sidebars: Three-panel layout with collapsible sidebars:
``` ```
┌─────────────────┬──────────────────────────┬─────────────┐ ┌─────────────────┬──────────────────────────┬─────────────┐
│ Session List │ Chat Window │ Info Panel │ │ Session List │ Chat Window │ Info Panel │
@@ -92,19 +127,19 @@ rules, inline styles for dynamic prop-driven values.
| Variable | Value | Description | | Variable | Value | Description |
|---|---|---| |---|---|---|
| `--bg-base` | `#0f1117` | Page background | | `--bg-base` | `#0f1117` | Page background |
| `--bg-surface` | `#1a1d27` | Panel backgrounds | | `--bg-surface` | `#0e0d0d` | Panel backgrounds |
| `--bg-elevated` | `#222536` | Elevated elements (inputs, cards) | | `--bg-elevated` | `#222536` | Elevated elements (inputs, cards) |
| `--border` | `#2e3150` | Border colour | | `--border` | `#2e3150` | Border colour |
| `--accent` | `#6c63ff` | Primary accent (buttons, highlights) | | `--accent` | `#3d3a79` | Primary accent (buttons, highlights) |
| `--accent-hover` | `#574fd6` | Accent hover state | | `--accent-hover` | `#574fd6` | Accent hover state |
| `--text-primary` | `#e8e8f0` | Primary text | | `--text-primary` | `#e8e8f0` | Primary text |
| `--text-secondary` | `#8b8fa8` | Secondary text | | `--text-secondary` | `#8b8fa8` | Secondary text |
| `--text-muted` | `#555870` | Muted / placeholder text | | `--text-muted` | `#555870` | Muted / placeholder text |
| `--bubble-user` | `#6c63ff` | User message bubble background | | `--bubble-user` | `#4742a8` | User message bubble background |
| `--bubble-ai` | `#222536` | AI message bubble background | | `--bubble-ai` | `#20264d` | AI message bubble background |
| `--sidebar-width` | `280px` | Expanded sidebar width | | `--sidebar-width` | `180px` | Expanded sidebar width |
| `--panel-width` | `260px` | Expanded info panel width | | `--panel-width` | `200px` | Expanded info panel width |
| `--header-height` | `56px` | Shared header height across all panels | | `--header-height` | `40px` | Shared header height across all panels |
| `--radius-sm` | `6px` | Small border radius | | `--radius-sm` | `6px` | Small border radius |
| `--radius-md` | `8px` | Medium border radius | | `--radius-md` | `8px` | Medium border radius |
| `--radius-lg` | `12px` | Large border radius | | `--radius-lg` | `12px` | Large border radius |
@@ -146,6 +181,7 @@ Uses a buffer pattern to handle SSE chunks that may span multiple network packet
## Streaming ## Streaming
The chat input sends messages via `POST /chat/stream`. Tokens arrive as SSE events: The chat input sends messages via `POST /chat/stream`. Tokens arrive as SSE events:
``` ```
data: {"text":"Hello"} data: {"text":"Hello"}
data: {"text":" Tim"} data: {"text":" Tim"}
@@ -165,16 +201,10 @@ The hook initialises with `FALLBACK_MODELS` from `constants.js` and replaces the
with the server response on success. If the fetch fails, the fallback list is used with the server response on success. If the fetch fails, the fallback list is used
silently — a warning is logged to the console. silently — a warning is logged to the console.
```js To add a model, update `models.json` on the main PC — no client rebuild needed.
// constants.js
export const FALLBACK_MODELS = [
{ value: 'companion:latest', label: 'Companion' },
// ...
];
```
The selected model is passed with every chat request. To add a model, update `FALLBACK_MODELS` in `constants.js` should be kept in sync with `models.json`
`models.json` on the main PC — no client rebuild needed. as a reasonable last-resort list in case the endpoint is unreachable.
## Session Management ## Session Management
@@ -183,24 +213,58 @@ Sessions are identified by `external_id` — a UUID generated client-side via th
service on the first message. The session list refreshes after each completed service on the first message. The session list refreshes after each completed
response to surface newly created sessions. response to surface newly created sessions.
### Session Name Display
The chat header and session list both display `session.name` if set, falling back
to `session.external_id` if no name has been assigned:
```js
activeSession.name || activeSession.external_id
```
### Session Actions ### Session Actions
The session list supports rename and delete: The session list supports rename and delete via two entry points:
- **Hover** — reveals ✎ (rename) and ✕ (delete) icon buttons on the session row - **Hover** — reveals ✎ (rename) and ✕ (delete) icon buttons alongside the session row
- **Right-click** — opens a context menu with the same actions - **Right-click** — opens a context menu with the same actions
Rename opens a `SessionModal` dialog. The modal is designed to expand into a full Both trigger `SessionModal` — a shared modal component with two modes:
session settings panel in future — the title is already "Session Settings" to
reflect this intent.
Delete is immediate with no confirmation dialog (planned for a future update). | Mode | Trigger | Behaviour |
|---|---|---|
| `settings` | Rename button / context menu rename | Shows name input, saves on Enter or Save button |
| `confirm-delete` | Delete button / context menu delete | Shows confirmation dialog with session name, requires explicit Delete button click |
Actions are disabled on unsaved (new) sessions that haven't had a message sent yet. The modal is intentionally titled "Session Settings" and structured to expand
into a full settings panel in future iterations.
Actions are disabled on unsaved (new) sessions that haven't had a first message sent yet.
### Active Session Clearing on Delete
When the deleted session is the currently active one, `App.jsx` detects the match
and calls `selectSession(null)` to clear the chat window before refreshing the list:
```js
function handleSessionsChange(deletedSession) {
if (deletedSession?.external_id === activeSession?.external_id) {
selectSession(null);
}
refreshSessions();
}
```
### Context Menu ### Context Menu
Implemented via `useContextMenu` hook — tracks `{ x, y, session }` state and Implemented via `useContextMenu` hook — tracks `{ x, y, session }` state and
attaches a `window` click listener to dismiss on any outside click. Rendered attaches a `window` click listener to dismiss on any outside click. Rendered
outside the sidebar div (via React fragment) to avoid being clipped by outside the sidebar div via a React fragment to avoid being clipped by
`overflow: hidden`. `overflow: hidden`.
### Button Nesting
Session row action icons (✎ ✕) are rendered as siblings of the session
`<button>`, not children — HTML does not allow `<button>` inside `<button>`.
The outer `<div>` owns hover state and context menu; the inner `<button>` handles
session selection; action icon buttons sit alongside it in the same flex row.

View File

@@ -35,6 +35,13 @@ export default function App() {
sendMessage(text, selectedModel); sendMessage(text, selectedModel);
} }
function handleSessionsChange(deletedSession){
if(deletedSession?.external_id === activeSession?.external_id){
selectSession(null);
}
refreshSessions();
}
return ( return (
<div style={{ <div style={{
display: 'flex', display: 'flex',
@@ -48,7 +55,7 @@ export default function App() {
onNewChat={createSession} onNewChat={createSession}
isOpen={leftOpen} isOpen={leftOpen}
onToggle={() => setLeftOpen(o => !o)} onToggle={() => setLeftOpen(o => !o)}
onSessionsChange={refreshSessions} onSessionsChange={handleSessionsChange}
/> />
<ChatWindow <ChatWindow

View File

@@ -30,7 +30,7 @@ export default function ChatWindow({ messages, loadingHistory, streaming, onSend
{/* Header */} {/* Header */}
<div className="panel-header" style={{ padding: '0 20px' }}> <div className="panel-header" style={{ padding: '0 20px' }}>
<span className="text-base text-secondary"> <span className="text-base text-secondary">
{activeSession ? activeSession.external_id : 'No session selected'} {activeSession ? ( activeSession.name || activeSession.external_id) : 'No session selected'}
</span> </span>
</div> </div>

View File

@@ -4,201 +4,212 @@ import { useContextMenu } from '../hooks/useContextMenu';
import { renameSession, deleteSession } from '../api/orchestration'; import { renameSession, deleteSession } from '../api/orchestration';
export default function SessionList({ sessions, activeSession, onSelectSession, onNewChat, isOpen, onToggle, onSessionsChange }) { export default function SessionList({ sessions, activeSession, onSelectSession, onNewChat, isOpen, onToggle, onSessionsChange }) {
const [modalSession, setModalSession] = useState(null); const [modalSession, setModalSession] = useState(null);
const [hoveredId, setHoveredId] = useState(null); const [hoveredId, setHoveredId] = useState(null);
const { menu, open: openMenu, close: closeMenu } = useContextMenu(); const { menu, open: openMenu, close: closeMenu } = useContextMenu();
const [modalMode, setModalMode] = useState('settings');
function formatDate(ts) { function formatDate(ts) {
if (!ts) return ''; if (!ts) return '';
const date = new Date(ts * 1000); const date = new Date(ts * 1000);
const now = new Date(); const now = new Date();
const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24)); const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24));
if (diffDays === 0) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); if (diffDays === 0) return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (diffDays === 1) return 'Yesterday'; if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return date.toLocaleDateString([], { weekday: 'long' }); if (diffDays < 7) return date.toLocaleDateString([], { weekday: 'long' });
return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
function getPreview(session) {
if (session.isNew) return 'New conversation';
return session.name || session.external_id;
}
async function handleRename(session, name) {
try {
await renameSession(session.external_id, name);
onSessionsChange();
} catch (err) {
console.error('[SessionList] Rename failed:', err.message);
} }
}
function getPreview(session) { async function handleDelete(session) {
if (session.isNew) return 'New conversation'; try {
return session.name || session.external_id; await deleteSession(session.external_id);
onSessionsChange();
} catch (err) {
console.error('[SessionList] Delete failed:', err.message);
} }
}
async function handleRename(session, name) { return (
try { <>
await renameSession(session.external_id, name); <div style={{
onSessionsChange(); width: isOpen ? 'var(--sidebar-width)' : '56px',
} catch (err) { flexShrink: 0,
console.error('[SessionList] Rename failed:', err.message); background: 'var(--bg-surface)',
} borderRight: '1px solid var(--border)',
} transition: 'width 0.2s ease',
overflow: 'hidden',
}} className="flex-col">
async function handleDelete(session) { {/* Header */}
try { <div className="panel-header" style={{
await deleteSession(session.external_id); justifyContent: isOpen ? 'space-between' : 'center',
onSessionsChange(); padding: isOpen ? '0 12px 0 16px' : '0',
} catch (err) { }}>
console.error('[SessionList] Delete failed:', err.message); {isOpen && <span className="text-base" style={{ fontWeight: 500, color: 'var(--text-secondary)' }}>Conversations</span>}
} <button className="btn-icon" onClick={onToggle}>{isOpen ? '◀' : '▶'}</button>
}
return (
<>
<div style={{
width: isOpen ? 'var(--sidebar-width)' : '56px',
flexShrink: 0,
background: 'var(--bg-surface)',
borderRight: '1px solid var(--border)',
transition: 'width 0.2s ease',
overflow: 'hidden',
}} className="flex-col">
{/* Header */}
<div className="panel-header" style={{
justifyContent: isOpen ? 'space-between' : 'center',
padding: isOpen ? '0 12px 0 16px' : '0',
}}>
{isOpen && <span className="text-base" style={{ fontWeight: 500, color: 'var(--text-secondary)' }}>Conversations</span>}
<button className="btn-icon" onClick={onToggle}>{isOpen ? '◀' : '▶'}</button>
</div>
{/* New chat button */}
<div style={{ padding: isOpen ? '12px' : '12px 8px', flexShrink: 0 }}>
<button className="btn-primary" onClick={onNewChat} style={{
width: '100%',
padding: isOpen ? '8px 12px' : '8px',
display: 'flex',
alignItems: 'center',
justifyContent: isOpen ? 'flex-start' : 'center',
gap: '8px',
whiteSpace: 'nowrap',
overflow: 'hidden',
}}>
<span style={{ fontSize: '18px', lineHeight: 1, flexShrink: 0 }}>+</span>
{isOpen && <span>New Chat</span>}
</button>
</div>
{/* Session list */}
<div className="flex-1 scroll-y">
{isOpen && sessions.map(session => {
const isActive = activeSession?.external_id === session.external_id;
const isHovered = hoveredId === session.external_id;
return (
<div
key={session.external_id}
onMouseEnter={() => setHoveredId(session.external_id)}
onMouseLeave={() => setHoveredId(null)}
onContextMenu={e => !session.isNew && openMenu(e, session)}
style={{
position: 'relative',
background: isActive ? 'var(--bg-elevated)' : isHovered ? 'var(--bg-elevated)' : 'transparent',
borderLeft: isActive ? '2px solid var(--accent)' : '2px solid transparent',
transition: 'background 0.1s',
}}
>
<button
onClick={() => onSelectSession(session)}
className="btn-reset"
style={{
width: '100%',
padding: '10px 16px',
textAlign: 'left',
flexDirection: 'column',
gap: '3px',
}}
>
<div className="flex" style={{ justifyContent: 'space-between', gap: '8px', width: '100%' }}>
<span className="text-base truncate" style={{
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: isActive ? 500 : 400,
flex: 1,
}}>
{getPreview(session)}
</span>
{/* Hover action icons */}
{isHovered && !session.isNew ? (
<div className="flex flex-shrink" style={{ gap: '4px' }}
onClick={e => e.stopPropagation()}
>
<button
className="btn-icon"
title="Rename"
onClick={() => setModalSession(session)}
style={{ padding: '2px 4px', fontSize: '12px' }}
></button>
<button
className="btn-icon"
title="Delete"
onClick={() => handleDelete(session)}
style={{ padding: '2px 4px', fontSize: '12px', color: '#ff6b6b' }}
></button>
</div>
) : (
<span className="text-xs text-muted flex-shrink">
{formatDate(session.updated_at)}
</span>
)}
</div>
{session.isNew && (
<span className="text-xs text-accent" style={{ fontStyle: 'italic' }}>Unsaved</span>
)}
</button>
</div>
);
})}
{isOpen && sessions.length === 0 && (
<div className="text-base text-muted" style={{ padding: '24px 16px', textAlign: 'center' }}>
No conversations yet
</div>
)}
</div>
</div> </div>
{/* Context menu */} {/* New chat button */}
{menu && ( <div style={{ padding: isOpen ? '12px' : '12px 8px', flexShrink: 0 }}>
<div <button className="btn-primary" onClick={onNewChat} style={{
onClick={e => e.stopPropagation()} width: '100%',
style={{ padding: isOpen ? '8px 12px' : '8px',
position: 'fixed', display: 'flex',
top: menu.y, alignItems: 'center',
left: menu.x, justifyContent: isOpen ? 'flex-start' : 'center',
background: 'var(--bg-elevated)', gap: '8px',
border: '1px solid var(--border)', whiteSpace: 'nowrap',
borderRadius: 'var(--radius-md)', overflow: 'hidden',
padding: '4px', }}>
zIndex: 50, <span style={{ fontSize: '18px', lineHeight: 1, flexShrink: 0 }}>+</span>
minWidth: '140px', {isOpen && <span>New Chat</span>}
}} </button>
> </div>
<button className="btn-reset text-base" onClick={() => { setModalSession(menu.session); closeMenu(); }}
style={{ width: '100%', padding: '8px 12px', borderRadius: 'var(--radius-sm)', justifyContent: 'flex-start', color: 'var(--text-primary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-surface)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
Rename
</button>
<button className="btn-reset text-base" onClick={() => { handleDelete(menu.session); closeMenu(); }}
style={{ width: '100%', padding: '8px 12px', borderRadius: 'var(--radius-sm)', justifyContent: 'flex-start', color: '#ff6b6b' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-surface)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
Delete
</button>
</div>
)}
{/* Rename modal */} {/* Session list */}
{modalSession && ( <div className="flex-1 scroll-y">
<SessionModal {isOpen && sessions.map(session => {
session={modalSession} const isActive = activeSession?.external_id === session.external_id;
onRename={handleRename} const isHovered = hoveredId === session.external_id;
onClose={() => setModalSession(null)}
/> return (
)} <div
</> key={session.external_id}
); onMouseEnter={() => setHoveredId(session.external_id)}
onMouseLeave={() => setHoveredId(null)}
onContextMenu={e => !session.isNew && openMenu(e, session)}
style={{
position: 'relative',
display: 'flex',
alignItems: 'stretch',
background: isActive ? 'var(--bg-elevated)' : isHovered ? 'var(--bg-elevated)' : 'transparent',
borderLeft: isActive ? '2px solid var(--accent)' : '2px solid transparent',
transition: 'background 0.1s',
}}
>
{/* Session select button — no action icons inside */}
<button
onClick={() => onSelectSession(session)}
className="btn-reset"
style={{
flex: 1,
padding: '10px 16px',
paddingRight: isHovered && !session.isNew ? '4px' : '16px',
textAlign: 'left',
flexDirection: 'column',
gap: '3px',
minWidth: 0, // allows truncation to work
}}
>
<div className="flex" style={{ gap: '8px', width: '100%' }}>
<span className="text-base truncate" style={{
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: isActive ? 500 : 400,
flex: 1,
}}>
{getPreview(session)}
</span>
{!isHovered && (
<span className="text-xs text-muted flex-shrink">
{formatDate(session.updated_at)}
</span>
)}
</div>
{session.isNew && (
<span className="text-xs text-accent" style={{ fontStyle: 'italic' }}>Unsaved</span>
)}
</button>
{/* Action icons — outside the button, alongside it */}
{isHovered && !session.isNew && (
<div className="flex items-center flex-shrink" style={{ gap: '2px', paddingRight: '8px' }}>
<button
className="btn-icon"
title="Rename"
onClick={() => { setModalMode('settings'); setModalSession(session); }}
style={{ padding: '2px 4px', fontSize: '12px' }}
></button>
<button
className="btn-icon"
title="Delete"
onClick={() => { setModalMode('confirm-delete'); setModalSession(session); }}
style={{ padding: '2px 4px', fontSize: '12px', color: '#ff6b6b' }}
></button>
</div>
)}
</div>
);
})}
{isOpen && sessions.length === 0 && (
<div className="text-base text-muted" style={{ padding: '24px 16px', textAlign: 'center' }}>
No conversations yet
</div>
)}
</div>
</div>
{/* Context menu */}
{menu && (
<div
onClick={e => e.stopPropagation()}
style={{
position: 'fixed',
top: menu.y,
left: menu.x,
background: 'var(--bg-elevated)',
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
padding: '4px',
zIndex: 50,
minWidth: '140px',
}}
>
<button
className="btn-reset text-base"
onClick={() => { setModalMode('settings'); setModalSession(menu.session); closeMenu(); }}
style={{ width: '100%', padding: '8px 12px', borderRadius: 'var(--radius-sm)', justifyContent: 'flex-start', color: 'var(--text-primary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-surface)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
>
Rename
</button>
<button className="btn-reset text-base"
onClick={() => { setModalMode('confirm-delete'); setModalSession(menu.session); closeMenu(); }}
style={{
width: '100%', padding: '8px 12px', borderRadius: 'var(--radius-sm)',
justifyContent: 'flex-start', color: '#ff6b6b'
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-surface)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
> Delete</button>
</div>
)}
{/* Rename modal */}
{modalSession && (
<SessionModal
session={modalSession}
mode={modalMode}
onRename={handleRename}
onDelete={handleDelete}
onClose={() => setModalSession(null)}
/>
)}
</>
);
} }

View File

@@ -1,108 +1,116 @@
import React, {useState, useEffect, useRef} from "react"; // SessionModal.jsx
import React, {useState, useEffect, useRef} from 'react';
export default function SessionModal({ session, onRename, onClose}) { export default function SessionModal({ session, mode = 'settings', onRename, onDelete, onClose }) {
const [name, setName] = useState(session?.name || ''); const [name, setName] = useState(session?.name || '');
const inputRef = useRef(null) const inputRef = useRef(null);
//Focus input when modal opens
useEffect(() => { useEffect(() => {
inputRef.current?.focus(); if (mode === 'settings') {
inputRef.current?.select(); inputRef.current?.focus();
}, []); inputRef.current?.select();
}
}, [mode]);
function handleSubmit() { function handleSubmit() {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) return; if (!trimmed) return;
onRename(session, trimmed); onRename(session, trimmed);
onClose(); onClose();
} }
function handleKeyDown(e) { function handleKeyDown(e) {
if (e.key === 'Enter') handleSubmit(); if (e.key === 'Enter' && mode === 'settings') handleSubmit();
if(e.key === 'Escape') onClose(); if (e.key === 'Escape') onClose();
} }
if (!session) return null; if (!session) return null;
return ( return (
//Backdrop <div onClick={onClose} style={{
<div position: 'fixed',
onClick={onClose} inset: 0,
style={{ background: 'rgba(0,0,0,0.5)',
position:'fixed', display: 'flex',
inset: 0, alignItems: 'center',
background: 'rgba(0,0,0,0.5)', justifyContent: 'center',
display:'flex', zIndex: 100,
alignItems: 'center', }}>
justifyContent: 'center', <div onClick={e => e.stopPropagation()} onKeyDown={handleKeyDown} style={{
zIndex: 100, background: 'var(--bg-surface)',
}} border: '1px solid var(--border)',
> borderRadius: 'var(--radius-lg)',
{/* Modal - stop click propagating to backdrop */} padding: '24px',
<div width: '360px',
onClick={e => e.stopPropagation()} display: 'flex',
style={{ flexDirection: 'column',
background: 'var(--bg-surface)', gap: '16px',
border: '1px solid var(--border)', }}>
borderRadius: 'var(--radius-lg)', {mode === 'settings' ? (
padding: '24px', <>
width: '360px', <h2 style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-primary)' }}>
display: 'flex', Session Settings
flexDirection: 'column', </h2>
gap: '16px', <div className="flex-col" style={{ gap: '8px' }}>
}} <label className="label-upper">Name</label>
> <input
{/* Modal Header*/} ref={inputRef}
<h2 style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-primary)' }}> value={name}
Session Settings onChange={e => setName(e.target.value)}
</h2> placeholder="Enter session name..."
style={{
{/* Modal Session name input*/} background: 'var(--bg-elevated)',
<div className="flex-col" style={{ gap: '8px' }}> border: '1px solid var(--border)',
<label className="label-upper">Name</label> borderRadius: 'var(--radius-md)',
<input padding: '8px 12px',
ref={inputRef} color: 'var(--text-primary)',
value={name} fontSize: '14px',
onChange={e => setName(e.target.value)} outline: 'none',
onKeyDown={handleKeyDown} width: '100%',
placeholder="Enter session name..." }}
style={{ />
background: 'var(--bg-elevated)', </div>
border: '1px solid var(--border)', <div className="flex" style={{ gap: '8px', justifyContent: 'flex-end' }}>
borderRadius: 'var(--radius-md)', <button className="btn-reset text-base text-muted"
padding: '8px 12px', onClick={onClose}
color: 'var(--text-primary)', style={{ padding: '8px 14px', borderRadius: 'var(--radius-md)' }}
fontSize: '14px', >Cancel</button>
outline: 'none', <button className="btn-primary" onClick={handleSubmit}
width: '100%', disabled={!name.trim()}
}} style={{ padding: '8px 16px' }}
/> >Save</button>
</div> </div>
</>
{/* Modal Buttons*/} ) : (
<div className="flex" style={{ gap: '8px', justifyContent: 'flex-end' }}> <>
<button className="btn-reset text-base text-muted" <h2 style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-primary)' }}>
onClick={onClose} Delete Session
style={{ padding: '8px 14px', borderRadius: 'var(--radius-md)' }} </h2>
> <p className="text-sm text-secondary">
Cancel Are you sure you want to delete{' '}
</button> <span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
<button {session.name || session.external_id}
className="btn-primary" </span>
onClick={handleSubmit} ? This will permanently remove all messages in this conversation.
disabled={!name.trim()} </p>
style={{ padding: '8px 16px' }} <div className="flex" style={{ gap: '8px', justifyContent: 'flex-end' }}>
> <button className="btn-reset text-base text-muted"
Save onClick={onClose}
</button> style={{ padding: '8px 14px', borderRadius: 'var(--radius-md)' }}
</div> >Cancel</button>
<button className="btn-reset text-base"
onClick={() => { onDelete(session); onClose(); }}
style={{
padding: '8px 16px',
borderRadius: 'var(--radius-md)',
background: '#c0392b',
color: 'white',
}}
>Delete</button>
</div>
</>
)}
</div> </div>
</div> </div>
);
)
} }

View File

@@ -11,6 +11,7 @@ export default defineConfig({
proxy: { proxy: {
'/chat': 'http://192.168.0.205:4000', '/chat': 'http://192.168.0.205:4000',
'/sessions': 'http://192.168.0.205:4000', '/sessions': 'http://192.168.0.205:4000',
'/models': 'http://192.168.0.205:4000',
}, },
}, },
}); });