16 KiB
Chat Client
Package: @nexusai/chat-client
Location: packages/chat-client
Deployed on: Mini PC 2 (192.168.0.205)
URL: https://nexus.jellystorm.com (behind Authelia SSO)
Purpose
Browser-based chat interface for NexusAI. Communicates exclusively with the orchestration service — no direct access to memory, embedding, or inference services. Served as static files by Caddy on Mini PC 2.
Dependencies
react+react-dom— UI frameworkreact-markdown— Markdown rendering in message bubbles and memory vieweruuid— session ID generationvite+@vitejs/plugin-react— build tooling
Build
cd packages/chat-client
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
file is only needed on the machine running the build, not where files are served.
Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
| VITE_ORCHESTRATION_URL | No | '' (empty) |
Orchestration base URL. Leave empty in dev (Vite proxy handles routing). Set to HTTPS domain for production builds. |
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
Do not set
VITE_ORCHESTRATION_URLto the HTTPS domain during local dev. Requests fromlocalhost:5173tonexus.jellystorm.comwill hit Authelia, which returns an HTML login page instead of JSON — causingUnexpected token '<'parse errors inuseModelsanduseSession.
Vite Dev Proxy
vite.config.js proxies API routes directly to the orchestration service
during local development, bypassing Caddy and Authelia entirely:
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',
'/projects': 'http://192.168.0.205:4000',
'/episodes': 'http://192.168.0.205:4000',
'/settings': 'http://192.168.0.205:4000',
'/health': 'http://192.168.0.205:4000',
}
}
});
When adding new top-level routes to the orchestration service, add a matching entry here and in the Caddy config.
Internal Structure
src/
├── api/
│ └── orchestration.js # All fetch calls to the orchestration service
├── config/
│ └── constants.js # FALLBACK_MODELS, DEFAULT_MODEL, API_DEFAULTS, CLIENT_DEFAULTS
├── hooks/
│ ├── useSession.js # Session list, history loading, active session state
│ ├── useChat.js # Message sending, SSE streaming, message state
│ ├── useModels.js # Dynamic model list fetched from /models endpoint
│ ├── useProjects.js # Project list fetched from /projects endpoint
│ ├── useSettings.js # Settings fetch + saveSetting helper
│ └── useContextMenu.js # Right-click context menu position and visibility
├── components/
│ ├── App.jsx # Root component — layout, shared state, view routing
│ ├── Sidebar.jsx # Left sidebar — projects, grouped recent chats, navigation
│ ├── HomeView.jsx # Landing screen — greeting, centred input, quick actions
│ ├── ChatWindow.jsx # Centre panel — message thread, back button, model pill
│ ├── MessageBubble.jsx # Individual message bubble — renders markdown via react-markdown
│ ├── InfoPanel.jsx # Right panel — model selector and session metadata (slide-in)
│ ├── SessionModal.jsx # Modal for session rename, project assignment, delete
│ ├── ProjectModal.jsx # Modal for project create, edit, delete
│ ├── AllChatsView.jsx # Paginated session list with project indicator column
│ ├── AllProjectsView.jsx # Project tile grid with create/edit/delete; tile click navigates to ProjectView
│ ├── ProjectView.jsx # Individual project — conversations, new chat input, memory
│ │ # placeholder, user notes, ⋮ edit/delete menu
│ ├── MemoryView.jsx # Paginated, searchable, expandable, deletable episode viewer
│ └── SettingsView.jsx # Settings — Memory limits, Models (inference params, active
│ # model, context window), Service Health, Appearance placeholder
├── index.css # Global reset, CSS variables, utility classes
└── main.jsx # React entry point
Layout
The app uses a view-based layout. App.jsx manages a view state string
that controls which main panel is rendered. The left sidebar and right info
panel are persistent across all views.
┌──────────────────┬──────────────────────────────┐
│ Sidebar │ Main Area (view-dependent) │
│ (collapsible) │ │
│ │ home → HomeView │
│ + New Chat │ chat → ChatWindow │
│ ⊞ View Projects │ all-chats → AllChatsView │
│ │ all-projects → AllProjectsView│
│ PROJECTS ▾ │ project → ProjectView │
│ [tile] [tile] │ settings → SettingsView │
│ All Projects → │ memory → MemoryView │
│ │ │
│ RECENT CHATS ▾ │ │
│ ● Project A │ │
│ Session 1 │ │
│ Session 2 │ │
│ ● Project B │ │
│ Session 3 │ │
│ Other │ │
│ Session 4 │ │
│ All Chats → │ │
│ │ │
│ ⚙ Settings │ │
└──────────────────┴──────────────────────────────┘
The sidebar collapses to a 48px icon rail and starts collapsed on the home
view. The right InfoPanel slides in from the right using
transform: translateX() — hidden by default, toggled via the ⊹ button
in the ChatWindow header.
View Routing
| View | Component | Trigger |
|---|---|---|
'home' |
HomeView |
Initial load; going back from chat with no history |
'chat' |
ChatWindow |
Selecting a session; new chat; sending from HomeView |
'all-chats' |
AllChatsView |
"All Chats →" or ☰ icon in collapsed rail |
'all-projects' |
AllProjectsView |
"View Projects" button or ⊞ icon |
'project' |
ProjectView |
Clicking a project tile in sidebar or AllProjectsView |
'settings' |
SettingsView |
Settings button or ⚙ icon |
'memory' |
MemoryView |
"Open →" button in Settings → Memory section |
activeProject state in App.jsx tracks which project ProjectView is
displaying. Set via onSelectProject before navigating to 'project'.
View History Stack
App.jsx maintains a viewHistory array. Each navigate(view) call pushes
the current view onto the stack. goBack() pops the last entry and restores
it. All view components receive onBack={goBack} — no component hardcodes
its own back destination. Navigating to 'home' collapses the sidebar;
leaving 'home' expands it.
Home View
HomeView is the landing screen shown on initial load. It displays:
- Time-based greeting ("Morning / Afternoon / Evening, Tim")
- Currently loaded model name (from
modelProps.modelAlias, stripped of.gguf) - Centred textarea input — sending creates a new session and navigates to chat
- Quick action pills that populate the input without auto-sending
Sending from HomeView uses handleHomeSend in App.jsx, which calls
createSession() (returns the new session object), then immediately calls
sendMessage with the session passed directly as a parameter — avoiding the
React state settling race condition that would cause the message to be dropped.
CSS Architecture
Styles follow a hybrid approach — CSS utility classes for static reusable rules, inline styles for dynamic prop-driven values.
CSS Variables (:root)
| Variable | Value | Description |
|---|---|---|
--bg-base |
#0f1117 |
Page background |
--bg-surface |
#0e0d0d |
Panel backgrounds |
--bg-elevated |
#222536 |
Elevated elements (inputs, cards) |
--border |
#2e3150 |
Border colour |
--accent |
#3d3a79 |
Primary accent (buttons, highlights) |
--accent-hover |
#574fd6 |
Accent hover state |
--text-primary |
#e8e8f0 |
Primary text |
--text-secondary |
#8b8fa8 |
Secondary text |
--text-muted |
#555870 |
Muted / placeholder text |
--bubble-user |
#4742a8 |
User message bubble background |
--bubble-ai |
#20264d |
AI message bubble background |
--sidebar-width |
180px |
Expanded sidebar width |
--panel-width |
200px |
Expanded info panel width |
--header-height |
40px |
Shared header height across all panels |
--radius-sm |
6px |
Small border radius |
--radius-md |
8px |
Medium border radius |
--radius-lg |
12px |
Large border radius |
Utility Classes
| Class | Description |
|---|---|
.panel-header |
Shared header row — used across all panels |
.btn-reset |
Resets button styles (no border, bg, cursor pointer) |
.btn-icon |
Icon button with hover state |
.btn-primary |
Accent-coloured action button with :hover and :disabled states |
.flex / .flex-col |
Flex layout helpers |
.flex-1 / .flex-shrink |
Flex sizing helpers |
.items-center / .justify-center / .justify-between |
Alignment helpers |
.overflow-hidden / .scroll-y |
Overflow helpers |
.text-xs / .text-sm / .text-base |
Font size helpers |
.text-muted / .text-secondary / .text-accent |
Colour helpers |
.label-upper |
Uppercase section label style |
.truncate |
Text overflow ellipsis |
Streaming
Messages are sent via POST /chat/stream. Tokens arrive as SSE events and
are written into the active assistant bubble token by token via
updateLastMessage. The blinking cursor in MessageBubble is shown while
message.streaming === true.
useChat.sendMessage accepts an optional session parameter (4th arg) that
overrides the closed-over activeSession. This is used by handleHomeSend
and handleNewProjectChat in App.jsx to pass the newly created session
object directly, avoiding React state settling races.
useChat accepts an optional projectId parameter in sendMessage. After
the first message completes in a new session, if projectId is set,
updateSession is called to write the project assignment to the backend.
Session Management
Sessions are identified by external_id — a UUID generated client-side via
the uuid package. New sessions are created locally and auto-registered in
the memory service on the first message. The session list refreshes after
each completed response to surface newly created sessions.
useSession.createSession returns the new session object — callers can pass
it directly to sendMessage rather than waiting for React state to update.
useSession.selectSession skips the history fetch for new (isNew: true)
sessions — fetching history for an unsaved session would 404 since it doesn't
exist in the backend yet.
Auto-naming
After the first exchange completes, orchestration fires a secondary inference
call with a short naming prompt (max 20 tokens, temperature 0.3). The result
is written back as session.name. The client fires a second refreshSessions
after a 3-second delay to pick up the name once written.
Manually renamed sessions are never overwritten — the !session.name guard
in chat/index.js prevents this.
Session Actions
Session rows support rename, project assignment, and delete via:
- Hover — reveals ✎ and ✕ icon buttons alongside the row
- Right-click — context menu with the same actions
SessionModal handles rename and project assignment together in settings
mode, and delete confirmation in confirm-delete mode.
Key Patterns
- Button nesting: action icons are siblings of row buttons, not children — HTML forbids
<button>inside<button> - Context menu rendered outside sidebar via React fragment to avoid
overflow: hiddenclipping useContextMenudismisses on awindowclick listener- Dynamic
updateSessionSQL buildsSETclause from only the fields passed — prevents accidental overwrites AllChatsViewpagination usesCLIENT_DEFAULTS.PAGE_SIZE(notAPI_DEFAULTS.PAGE_SIZEwhich doesn't exist)
Sidebar — Session Grouping
Recent sessions in the sidebar are grouped by project under a colour dot +
project name label. Unassigned sessions appear under "Other" if any project
groups are present. The grouping is computed client-side from the sessions
array and projects list already available in App.jsx — no extra API call.
AllChatsView receives projects as a prop from App.jsx and displays a
project indicator column (colour dot + truncated name) in each session row.
Project Management
All projects are isolated by default (isolated: 1 hardcoded on create).
The isolated toggle has been removed from ProjectModal.
useProjects fetches the project list from GET /projects on mount and
exposes refreshProjects for keeping the sidebar in sync after mutations.
ProjectModal handles create, edit, and delete confirmation. Fields: name
(required), description (optional), colour picker.
Clicking a project tile in AllProjectsView calls onSelectProject then
navigates to 'project'.
ProjectView
ProjectView is a full project workspace with:
- Colour accent bar + project title + description
- ⋮ dropdown menu for edit (opens
ProjectModalpre-filled) and delete - Conversations list — each session is a clickable row navigating to
'chat' ChatInputcomponent below the list (or centred when no sessions exist) for starting new project-tied conversations without a separate button- Project Memory — placeholder section explaining upcoming auto-summary feature
- Project Notes — textarea with Save button; notes saved to
projects.notescolumn in SQLite; save button only appears when content has changed from last saved value (savedNotesstate tracks the baseline, notinitialNotes)
updateProject in orchestration.js uses a passthrough pattern — spreads
all fields directly into the request body, only transforming isolated if
present. This allows partial updates like { notes } without clobbering
other fields.
For memory isolation behaviour, see memory-isolation.md.
Settings
useSettings fetches from GET /settings on mount and exposes a
saveSetting(key, value) helper that issues a PATCH /settings with a
single key-value pair. The saving boolean is exposed for disabling save
buttons during in-flight requests.
SettingsView receives settings/saveSetting/saving from a single
useSettings() call at the top level and passes them as props to
ModelsSection and ModelsFolderSetting — avoiding triple fetch on mount.
modelProps (context window, loaded model) is fetched once in App.jsx and
passed down as a prop, eliminating a duplicate fetch on every settings open.
SettingsView is organised into sections:
- Memory — recent episode limit, semantic limit, score threshold, link to MemoryView
- Models — models folder path, temperature, repeat penalty, Top-P, Top-K, active model dropdown, read-only model info panel (file, size, context window, loaded model from llama-server)
- About — service health check panel, version
- Appearance — theme (coming soon)
An error boundary (SettingsSectionErrorBoundary) wraps the Models section —
if the models fetch fails, only that section shows an error with a Retry
button rather than blanking the entire settings view.