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

@@ -7,6 +7,7 @@ export default function SessionList({ sessions, activeSession, onSelectSession,
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 '';
@@ -93,23 +94,28 @@ export default function SessionList({ sessions, activeSession, onSelectSession,
onContextMenu={e => !session.isNew && openMenu(e, session)} onContextMenu={e => !session.isNew && openMenu(e, session)}
style={{ style={{
position: 'relative', position: 'relative',
display: 'flex',
alignItems: 'stretch',
background: isActive ? 'var(--bg-elevated)' : isHovered ? 'var(--bg-elevated)' : 'transparent', background: isActive ? 'var(--bg-elevated)' : isHovered ? 'var(--bg-elevated)' : 'transparent',
borderLeft: isActive ? '2px solid var(--accent)' : '2px solid transparent', borderLeft: isActive ? '2px solid var(--accent)' : '2px solid transparent',
transition: 'background 0.1s', transition: 'background 0.1s',
}} }}
> >
{/* Session select button — no action icons inside */}
<button <button
onClick={() => onSelectSession(session)} onClick={() => onSelectSession(session)}
className="btn-reset" className="btn-reset"
style={{ style={{
width: '100%', flex: 1,
padding: '10px 16px', padding: '10px 16px',
paddingRight: isHovered && !session.isNew ? '4px' : '16px',
textAlign: 'left', textAlign: 'left',
flexDirection: 'column', flexDirection: 'column',
gap: '3px', gap: '3px',
minWidth: 0, // allows truncation to work
}} }}
> >
<div className="flex" style={{ justifyContent: 'space-between', gap: '8px', width: '100%' }}> <div className="flex" style={{ gap: '8px', width: '100%' }}>
<span className="text-base truncate" style={{ <span className="text-base truncate" style={{
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)', color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: isActive ? 500 : 400, fontWeight: isActive ? 500 : 400,
@@ -117,26 +123,7 @@ export default function SessionList({ sessions, activeSession, onSelectSession,
}}> }}>
{getPreview(session)} {getPreview(session)}
</span> </span>
{!isHovered && (
{/* 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"> <span className="text-xs text-muted flex-shrink">
{formatDate(session.updated_at)} {formatDate(session.updated_at)}
</span> </span>
@@ -146,6 +133,24 @@ export default function SessionList({ sessions, activeSession, onSelectSession,
<span className="text-xs text-accent" style={{ fontStyle: 'italic' }}>Unsaved</span> <span className="text-xs text-accent" style={{ fontStyle: 'italic' }}>Unsaved</span>
)} )}
</button> </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> </div>
); );
})} })}
@@ -174,20 +179,24 @@ export default function SessionList({ sessions, activeSession, onSelectSession,
minWidth: '140px', minWidth: '140px',
}} }}
> >
<button className="btn-reset text-base" onClick={() => { setModalSession(menu.session); closeMenu(); }} <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)' }} 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)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-surface)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
> >
Rename Rename
</button> </button>
<button className="btn-reset text-base" onClick={() => { handleDelete(menu.session); closeMenu(); }} <button className="btn-reset text-base"
style={{ width: '100%', padding: '8px 12px', borderRadius: 'var(--radius-sm)', justifyContent: 'flex-start', color: '#ff6b6b' }} 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)'} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-surface)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
> > Delete</button>
Delete
</button>
</div> </div>
)} )}
@@ -195,7 +204,9 @@ export default function SessionList({ sessions, activeSession, onSelectSession,
{modalSession && ( {modalSession && (
<SessionModal <SessionModal
session={modalSession} session={modalSession}
mode={modalMode}
onRename={handleRename} onRename={handleRename}
onDelete={handleDelete}
onClose={() => setModalSession(null)} onClose={() => setModalSession(null)}
/> />
)} )}

View File

@@ -1,48 +1,42 @@
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(() => {
if (mode === 'settings') {
inputRef.current?.focus(); inputRef.current?.focus();
inputRef.current?.select(); 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}
style={{
position:'fixed',
inset: 0, inset: 0,
background: 'rgba(0,0,0,0.5)', background: 'rgba(0,0,0,0.5)',
display:'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
zIndex: 100, zIndex: 100,
}} }}>
> <div onClick={e => e.stopPropagation()} onKeyDown={handleKeyDown} style={{
{/* Modal - stop click propagating to backdrop */}
<div
onClick={e => e.stopPropagation()}
style={{
background: 'var(--bg-surface)', background: 'var(--bg-surface)',
border: '1px solid var(--border)', border: '1px solid var(--border)',
borderRadius: 'var(--radius-lg)', borderRadius: 'var(--radius-lg)',
@@ -51,21 +45,18 @@ export default function SessionModal({ session, onRename, onClose}) {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
gap: '16px', gap: '16px',
}} }}>
> {mode === 'settings' ? (
{/* Modal Header*/} <>
<h2 style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-primary)' }}> <h2 style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-primary)' }}>
Session Settings Session Settings
</h2> </h2>
{/* Modal Session name input*/}
<div className="flex-col" style={{ gap: '8px' }}> <div className="flex-col" style={{ gap: '8px' }}>
<label className="label-upper">Name</label> <label className="label-upper">Name</label>
<input <input
ref={inputRef} ref={inputRef}
value={name} value={name}
onChange={e => setName(e.target.value)} onChange={e => setName(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Enter session name..." placeholder="Enter session name..."
style={{ style={{
background: 'var(--bg-elevated)', background: 'var(--bg-elevated)',
@@ -79,30 +70,47 @@ export default function SessionModal({ session, onRename, onClose}) {
}} }}
/> />
</div> </div>
{/* Modal Buttons*/}
<div className="flex" style={{ gap: '8px', justifyContent: 'flex-end' }}> <div className="flex" style={{ gap: '8px', justifyContent: 'flex-end' }}>
<button className="btn-reset text-base text-muted" <button className="btn-reset text-base text-muted"
onClick={onClose} onClick={onClose}
style={{ padding: '8px 14px', borderRadius: 'var(--radius-md)' }} style={{ padding: '8px 14px', borderRadius: 'var(--radius-md)' }}
> >Cancel</button>
Cancel <button className="btn-primary" onClick={handleSubmit}
</button>
<button
className="btn-primary"
onClick={handleSubmit}
disabled={!name.trim()} disabled={!name.trim()}
style={{ padding: '8px 16px' }} style={{ padding: '8px 16px' }}
> >Save</button>
Save
</button>
</div> </div>
</>
) : (
<>
<h2 style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-primary)' }}>
Delete Session
</h2>
<p className="text-sm text-secondary">
Are you sure you want to delete{' '}
<span style={{ color: 'var(--text-primary)', fontWeight: 500 }}>
{session.name || session.external_id}
</span>
? This will permanently remove all messages in this conversation.
</p>
<div className="flex" style={{ gap: '8px', justifyContent: 'flex-end' }}>
<button className="btn-reset text-base text-muted"
onClick={onClose}
style={{ padding: '8px 14px', borderRadius: 'var(--radius-md)' }}
>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',
}, },
}, },
}); });