| Concern | Solution |
|---|---|
| Framework | Vue 3 (Composition API) |
| Build | Vite |
| Language | JavaScript (ES modules) |
| State | Pinia |
| UI kit | gnexus-ui-kit (SCSS source + JS utilities) |
| Markdown | marked.js |
| Syntax highlight | highlight.js + summerfruit-dark theme (from UI kit) |
| Icons | Phosphor Icons (font, from UI kit) |
| Virtual scroll | vue-virtual-scroller |
| Deployment | Vite builds to dist/, served via FastAPI /static/ |
navi-webclient/ ├── index.html ├── vite.config.js ├── package.json ├── public/ │ └── assets/ # copied from gnexus-ui-kit/public/assets (fonts, icons) ├── src/ │ ├── main.js # app entry: Vue, Pinia, global styles │ ├── App.vue # root: layout shell, sidebar + chat area │ ├── components/ │ │ ├── sidebar/ │ │ │ ├── AppSidebar.vue │ │ │ ├── SessionList.vue │ │ │ └── SessionItem.vue │ │ ├── chat/ │ │ │ ├── ChatArea.vue │ │ │ ├── ChatHeader.vue │ │ │ ├── MessageList.vue │ │ │ ├── InputBar.vue │ │ │ └── FilePreviewStrip.vue │ │ ├── messages/ │ │ │ ├── UserMessage.vue │ │ │ ├── AssistantMessage.vue │ │ │ ├── ThinkingCard.vue │ │ │ ├── ToolCard.vue │ │ │ ├── SubagentStep.vue │ │ │ ├── SummaryCard.vue │ │ │ └── CompressionNotice.vue │ │ └── ui/ │ │ ├── WelcomeScreen.vue │ │ ├── ContextBar.vue # token indicator │ │ └── ProfileBadge.vue │ ├── stores/ │ │ ├── sessions.js │ │ ├── profiles.js │ │ └── chat.js │ ├── composables/ │ │ ├── useWebSocket.js # WS connection lifecycle │ │ └── useFileUpload.js # drag-drop + file/image handling │ ├── api/ │ │ └── index.js # REST API wrapper (fetch-based) │ └── styles/ │ ├── main.scss # imports kit + overrides │ └── overrides.scss # project-specific deviations from kit
Hash-based, no router library needed:
/# or /#<session_id> — load session by IDlocation.hash → load session or show welcomelocation.hash = session_id (no page reload)hashchange event → switch active sessionuseSessionsStorestate: {
sessions: [], // Session[] — full list from GET /sessions
currentId: null, // active session_id
}
actions:
fetchSessions() // GET /sessions
createSession(profileId) // POST /sessions
deleteSession(id) // DELETE /sessions/{id}
pinSession(id, pinned) // PATCH /sessions/{id}/pin
setCurrentId(id)
Designed to support pagination later: sessions will become a partial list when the API adds pagination. Keep fetch logic isolated here.
useProfilesStorestate: {
profiles: [], // AgentProfile[]
selectedProfileId: null
}
actions:
fetchProfiles() // GET /agents/profiles
useChatStorestate: {
messages: [], // rendered message list for current session
streaming: false,
streamingMessageId: null,
pendingImages: [], // base64 strings
pendingFiles: [], // uploaded file objects {name, path, size}
contextTokens: 0,
maxContextTokens: 0,
}
actions:
loadSession(id) // GET /sessions/{id} → populate messages
clearChat()
appendMessage(msg)
updateStreamingMessage(delta)
finalizeStreaming(data)
setContext(tokens, max)
useWebSocket.jsComposable (not a store) — manages one connection per active session.
connect(sessionId)
→ open ws://localhost:8000/ws/sessions/{id}
→ on open: clear reconnect state
→ on message: dispatch to chat store
→ on close/error: start reconnect sequence
// composables/useWebSocket.js
function getAuthHeaders() {
// TODO: return auth token when multi-user is ready
return {};
}
The WS URL and any future auth token injection go through this single function.
| Event | Action |
|---|---|
stream_start |
chat.streaming = true, create streaming message slot |
thinking_delta |
append to thinking card text |
thinking_end |
collapse thinking card |
turn_thinking |
render complete thinking block (collapsed) |
plan_ready |
render plan card (collapsed) |
tool_started |
create pending tool card with spinner |
tool_call |
finalize tool card (result, success/failure color) |
stream_delta |
append to streaming bubble, re-render markdown |
stream_end |
finalize bubble, streaming = false, update context tokens |
stream_stopped |
streaming = false, re-enable input |
profile_switched |
update active profile in header |
context_compressed |
append compression notice |
error |
append inline error message to chat |
AppSidebar.vueDrawer component)ChatHeader on mobile<select>, "+ New Chat" button, SessionListSessionList.vueUses vue-virtual-scroller (RecycleScroller) for performance. Each item is a SessionItem.
localStorage keyed by session_id (restored on session switch)MessageList.vueUses vue-virtual-scroller with variable item height (DynamicScroller).
Message type routing:
// each item in messages[] has a `role` and optional `type` // role: 'user' | 'assistant' | 'system' // type: 'summary' | 'compression_notice' | undefined
ThinkingCard.vue<details> elementthinking_endToolCard.vue$color-secondary (pending) → $color-success / $color-errorTOOL_ICONS map; unknown → ph-wrenchSubagentStep.vue) nested inside spawn_agent cardsInputBar.vue<input type="file" multiple>dragover/drop on the entire ChatArea — highlight drop zone on drag enterpaste event → base64POST /sessions/{id}/stopContextBar.vueShown in ChatHeader. Visible once first stream_end received.
[████████░░░░] 12,450 / 32,000 tokens (38%)
WelcomeScreen.vueShown when no sessions exist (first launch). Contains:
Import kit source directly (not compiled) so updates propagate:
// src/styles/main.scss @use '../../node_modules/gnexus-ui-kit/src/scss/kit' as *; @use './overrides';
The kit is added as a git submodule or npm-linked local package. Do not copy-paste kit SCSS into this project.
Override only via src/styles/overrides.scss — never edit kit files directly.
Called imperatively where needed, not wrapped in Vue components:
// example: show toast from any composable or component
import { Toasts } from 'gnexus-ui-kit'
Toasts.createError('Connection lost', 'Reconnecting…').show()
Components that require DOM init (Accordion, etc.) call .init() in onMounted.
<i class="ph ph-paper-plane-tilt"></i> <!-- send --> <i class="ph ph-sidebar"></i> <!-- toggle sidebar --> <i class="ph ph-stop-circle"></i> <!-- stop generation -->
┌─────────────┬──────────────────────────────┐ │ Sidebar │ Chat area │ │ 280px │ flex: 1 │ │ fixed │ │ └─────────────┴──────────────────────────────┘
┌──────────────────────────────────────────┐ │ [☰] Session title [tokens] │ ← ChatHeader ├──────────────────────────────────────────┤ │ │ │ Messages │ │ │ ├──────────────────────────────────────────┤ │ [📎] [textarea...] [send ↑] │ ← InputBar └──────────────────────────────────────────┘ Sidebar: full-screen drawer, opens over content
Using kit motion tokens ($motion-fast: 0.15s, $motion-base: 0.2s, $motion-slow: 0.28s).
| Element | Animation |
|---|---|
| Session switch | Chat area fade-out → fade-in (0.15s) |
| New message appear | Slide up + fade in (0.2s) |
| Thinking/tool card expand | Height transition via <details> + CSS (0.2s) |
| Sidebar drawer (mobile) | Slide in from left (0.28s ease) |
| Toast | Kit's built-in a-show / a-hide classes |
| Streaming text | No animation — raw append for performance |
Vue <Transition> components used for session switch and message appearance.
| Error type | Display |
|---|---|
| Network / WS disconnect | Toast (kit Toasts.createError) |
| WS reconnect failed (3 attempts) | Persistent toast with status |
| Agent error event from server | Inline in chat (red error bubble) |
| REST API failure (create session, delete, etc.) | Toast |
| File upload failure | Toast + remove file from preview strip |
paste event) or file picker (image types)FileReader.readAsDataURL() → base64 → pendingImages[]FilePreviewStrip with remove button{ images: [...base64] }POST /sessions/{id}/files (multipart){ name, path, size, content_type } → pendingFiles[]FilePreviewStrip{ files: [...] }Drag-and-drop: visual highlight on ChatArea when dragging over, handled in useFileUpload.js composable shared between drag events and the attach button.
// vite.config.js
export default {
build: {
outDir: '../navi-1/client', // outputs directly into FastAPI's static dir
emptyOutDir: true
}
}
FastAPI serves the built files from GET /static/**. index.html is served from GET / by the existing catch-all route.
1. main.js → mount App, init Pinia
2. App.vue onMounted:
a. profilesStore.fetchProfiles() → populate sidebar profile selector
b. sessionsStore.fetchSessions() → populate session list
c. Read location.hash
├─ valid session_id → chatStore.loadSession(id) → connect WS
└─ no sessions at all → show WelcomeScreen
3. loadSession(id):
a. GET /sessions/{id} → render message history
b. Update location.hash
c. useWebSocket.connect(id)
d. Restore draft from localStorage