feat(jef-133): desktop chat dock — persistent footer + floating minimizable assistant widget - #325
Conversation
…izable assistant widget Adds a persistent desktop footer, present on every logged-in page, that lets the user pop the AI assistant open as a floating chat widget without navigating away, minimize it back down without losing the conversation, keep several conversations pinned at once, and jump to the full /assistant page for a conversation on demand. Desktop only (lg: breakpoint) — mobile keeps its existing bottom-nav + full-page assistant flow unchanged. Extracted a shared ChatConversationView out of AssistantPage.tsx (message list, optimistic send, the rotating "thinking" state, AI_NOT_CONFIGURED handling) so the full page and the floating widget can't drift apart — both are now thin wrappers supplying their own header chrome and provider/model picker around the same core. New ChatDockProvider (React Context, mirroring the existing ThemeProvider precedent — this app has no Redux/Zustand/Jotai) holds only conversation ids, never conversation data itself: titles/messages stay in TanStack Query's cache, looked up by id wherever rendered, so dock state can't go stale relative to the real data. Mounted inside AuthenticatedLayout (not __root.tsx, which has no auth awareness) so it persists across in-app navigation within the authenticated route tree but doesn't exist pre-login. v1 is in-memory only, by design — a full page reload loses pinned/expanded state, same as any other client-only UI state in this app; the underlying conversations are safely persisted server-side regardless. ChatDockFloatingWindow reuses NotificationInboxPanel's createPortal-to-document.body pattern to escape the sidebar's stacking/transform context, but isn't a modal: no backdrop, no click-outside/Escape-to-close, since it's meant to sit alongside the page rather than block it. Maximize navigates to /assistant with the same conversationId and closes the dock section — since both surfaces read/write the same conversationsQueryOptions/chatHistoryQueryOptions cache keys, the full page picks up exactly where the widget left off with zero extra sync work. Extended CHAT_HISTORY_QUERY to select id/createdAt (already exposed by MessageType, previously unused) for stable React list keys; optimistic messages get a locally-generated id via the same ChatMessage shape. Three decisions called out as open in JEF-133 were made during implementation rather than left blocking: maximize closes the dock pill (the full page now owns showing that conversation); the footer renders on every authenticated page including /assistant itself, for a consistent mental model; v1 has no sessionStorage persistence of pinned sections. Manually verified end-to-end in the browser: footer → picker → new conversation → send → minimize → pill persists across a real client-side route navigation (not just a page that doesn't reload) → reopen from pill → maximize → full page shows the same conversation history via the shared query cache.
WalkthroughThe PR extracts shared assistant conversation handling, adds message identifiers and timestamps, introduces in-memory chat dock state, and integrates picker, footer, floating-window, and responsive navigation components into the authenticated layout. ChangesAssistant chat flow
Desktop chat dock
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant ChatDockFooter
participant ChatDockProvider
participant ChatDockFloatingWindow
participant ChatConversationView
User->>ChatDockFooter: Select a conversation
ChatDockFooter->>ChatDockProvider: Open or minimise conversation
ChatDockProvider->>ChatDockFloatingWindow: Update dock state
ChatDockFloatingWindow->>ChatConversationView: Render compact view
ChatConversationView->>ChatDockFloatingWindow: Report new conversation
ChatDockFloatingWindow->>ChatDockProvider: Promote conversation
Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (1)
apps/web/src/routes/_authenticated/-chat-dock-floating-window.tsx (1)
25-34: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winDefer dock queries until the dock needs their data.
AuthenticatedLayoutmounts both dock components on every authenticated page. Their queries run while the dock is inactive, including on small viewports where CSS hides the dock. Use React Queryenabledoptions to fetch only whendock.expandedis set ordock.sections.length > 0.
apps/web/src/routes/_authenticated/-chat-dock-floating-window.tsx#L25-L34: enable conversation and LLM-key queries only while a conversation is expanded.apps/web/src/routes/_authenticated/-chat-dock-footer.tsx#L17-L18: enable the conversation-title query only when the footer has pinned sections.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/routes/_authenticated/-chat-dock-floating-window.tsx` around lines 25 - 34, Defer the conversation and LLM-key queries in apps/web/src/routes/_authenticated/-chat-dock-floating-window.tsx:25-34 by setting their React Query enabled conditions to dock.expanded, while preserving existing query options and data handling. Also update the conversation-title query in apps/web/src/routes/_authenticated/-chat-dock-footer.tsx:17-18 to enable fetching only when dock.sections.length > 0.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/web/src/routes/_authenticated/-chat-dock-floating-window.tsx`:
- Around line 96-102: Update the ChatConversationView usage in the floating chat
dock so local draft state resets whenever dock.expanded changes, preventing text
from one conversation from carrying into another; use a key based on
dock.expanded or equivalent conversationId-change reset, and add a regression
test that types a draft, switches conversations, and verifies the draft is
cleared.
In `@apps/web/src/routes/_authenticated/-chat-dock-footer.tsx`:
- Around line 52-75: Update the conversation pill markup around togglePill so
the title/activation area is a labelled, focusable button instead of a clickable
div, preserving the expanded styling and Enter/Space activation behavior. Keep
the close button as a sibling with its existing stopPropagation and closeSection
behavior, and add tests covering Enter and Space activation for toggling a
conversation.
In `@apps/web/src/routes/_authenticated/-components/AuthenticatedLayout.tsx`:
- Line 123: Add an integration test for AuthenticatedLayout that uses the
established gqlClient and router mocks, renders the layout, and verifies the
desktop ChatDock mounts within ChatDockProvider, covering the ChatDockProvider,
ChatDockFooter, and ChatDockFloatingWindow integration.
In
`@apps/web/src/routes/_authenticated/assistant/-components/ChatConversationView.tsx`:
- Around line 107-147: Update handleSend so conversation creation and message
sending are handled within a shared error path: preserve or restore the trimmed
draft when createConversation.mutateAsync fails, expose a common mutation error
for either failure, and ensure failed optimistic user messages are removed or
explicitly marked failed with a retry action instead of remaining normal
history. Update the component’s rendered error state and add regression tests
covering both createConversation and send mutation rejections.
- Around line 27-29: Update tempMessageId() to generate optimistic message
identifiers with nanoid(), importing nanoid from the nanoid package and
preserving the existing optimistic identifier prefix.
---
Nitpick comments:
In `@apps/web/src/routes/_authenticated/-chat-dock-floating-window.tsx`:
- Around line 25-34: Defer the conversation and LLM-key queries in
apps/web/src/routes/_authenticated/-chat-dock-floating-window.tsx:25-34 by
setting their React Query enabled conditions to dock.expanded, while preserving
existing query options and data handling. Also update the conversation-title
query in apps/web/src/routes/_authenticated/-chat-dock-footer.tsx:17-18 to
enable fetching only when dock.sections.length > 0.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: f5efe84a-1942-4063-af04-5c1f98448d1a
📒 Files selected for processing (13)
apps/web/src/__tests__/components/AssistantChatPage.test.tsxapps/web/src/__tests__/components/ChatConversationView.test.tsxapps/web/src/__tests__/components/ChatDockFloatingWindow.test.tsxapps/web/src/__tests__/components/ChatDockFooter.test.tsxapps/web/src/__tests__/lib/chatDock.test.tsxapps/web/src/lib/chatDock.tsxapps/web/src/routes/_authenticated/-chat-dock-floating-window.tsxapps/web/src/routes/_authenticated/-chat-dock-footer.tsxapps/web/src/routes/_authenticated/-chat-dock-picker.tsxapps/web/src/routes/_authenticated/-components/AuthenticatedLayout.tsxapps/web/src/routes/_authenticated/assistant/-components/AssistantPage.tsxapps/web/src/routes/_authenticated/assistant/-components/ChatConversationView.tsxapps/web/src/routes/_authenticated/assistant/-shared.ts
| <ChatConversationView | ||
| conversationId={isNew ? null : (dock.expanded as string)} | ||
| provider={defaultProvider} | ||
| model="" | ||
| onConversationCreated={(id) => dock.promoteNewConversation(id)} | ||
| compact | ||
| /> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Reset local draft state when the expanded conversation changes.
ChatConversationView keeps input in local state. If a user types text in conversation A and opens conversation B, this component receives new props without remounting. The draft from A can then be sent to B. Set key={dock.expanded} on ChatConversationView, or clear local draft state when conversationId changes. Add a regression test that switches conversations after typing a draft.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/routes/_authenticated/-chat-dock-floating-window.tsx` around
lines 96 - 102, Update the ChatConversationView usage in the floating chat dock
so local draft state resets whenever dock.expanded changes, preventing text from
one conversation from carrying into another; use a key based on dock.expanded or
equivalent conversationId-change reset, and add a regression test that types a
draft, switches conversations, and verifies the draft is cleared.
| <div | ||
| key={id} | ||
| onClick={() => togglePill(id)} | ||
| className={`flex items-center gap-1.5 pl-3 pr-1.5 py-1.5 rounded-lg text-sm cursor-pointer transition-colors shrink-0 ${ | ||
| dock.expanded === id | ||
| ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300' | ||
| : 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' | ||
| }`} | ||
| > | ||
| <span className="max-w-[10rem] truncate"> | ||
| {titleById.get(id) ?? 'New conversation'} | ||
| </span> | ||
| <button | ||
| type="button" | ||
| onClick={(e) => { | ||
| e.stopPropagation(); | ||
| dock.closeSection(id); | ||
| }} | ||
| aria-label="Close conversation" | ||
| className="p-0.5 text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 rounded" | ||
| > | ||
| <XIcon size={12} /> | ||
| </button> | ||
| </div> |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Make each conversation pill keyboard-operable.
The clickable pill is a div, so it cannot receive focus or process keyboard activation. Keyboard users cannot reopen or minimise a pinned conversation. Put togglePill(id) on a labelled button for the conversation title. Keep the close button as a sibling. Add Enter and Space activation tests.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/routes/_authenticated/-chat-dock-footer.tsx` around lines 52 -
75, Update the conversation pill markup around togglePill so the
title/activation area is a labelled, focusable button instead of a clickable
div, preserving the expanded styling and Enter/Space activation behavior. Keep
the close button as a sibling with its existing stopPropagation and closeSection
behavior, and add tests covering Enter and Space activation for toggling a
conversation.
| <div className="min-h-screen flex bg-gray-50 dark:bg-gray-900"> | ||
| <CommandPalette /> | ||
| <ShortcutCheatSheet isOpen={shortcutsOpen} onClose={() => setShortcutsOpen(false)} /> | ||
| <ChatDockProvider> |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add a matching AuthenticatedLayout integration test.
This change mounts ChatDockProvider, ChatDockFooter, and ChatDockFloatingWindow in AuthenticatedLayout, but no matching layout test is included. Add a test that renders the authenticated layout with the established gqlClient and router mocks. Verify that the desktop dock mounts inside its provider.
As per coding guidelines, “Every new or changed use case, resolver, repository, React component, page, or utility function must ship with matching tests in the same change.”
Also applies to: 336-337
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/web/src/routes/_authenticated/-components/AuthenticatedLayout.tsx` at
line 123, Add an integration test for AuthenticatedLayout that uses the
established gqlClient and router mocks, renders the layout, and verifies the
desktop ChatDock mounts within ChatDockProvider, covering the ChatDockProvider,
ChatDockFooter, and ChatDockFloatingWindow integration.
Source: Coding guidelines
| function tempMessageId(): string { | ||
| return `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; | ||
| } |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Confirm that nanoid is available and inspect established project usage.
fd -a '^package\.json$' . -x rg -n '"nanoid"' {}
rg -n -C 2 --type ts --type tsx "from ['\"]nanoid['\"]|nanoid\s*\(" apps/web/srcRepository: mankatcheung/job-finder
Length of output: 188
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf 'Changed file outline/context:\n'
ast-grep outline apps/web/src/routes/_authenticated/assistant/-components/ChatConversationView.tsx --view expanded || true
printf '\nRelevant tempMessageId area:\n'
sed -n '1,90p' apps/web/...src/routes/_authenticated/assistant/-components/ChatConversationView.tsx 2>/dev/null || sed -n '1,90p' apps/web/src/routes/_authenticated/assistant/-components/ChatConversationView.tsx 2>/dev/null
printf '\nAll tempMessageId definitions/usages:\n'
rg -n -C 2 'tempMessageId|optimistic-' apps/web/src/routes/_authenticated/assistant/-components/ChatConversationView.tsx || true
printf '\nAll nanoid usages in apps/web/src:\n'
rg -n -C 2 "from ['\"]nanoid['\"]|nanoid\s*\(" apps/web/src || true
printf '\nPackage scripts/features relevant to tsx path:\n'
python3 - <<'PY'
import json
from pathlib import Path
for p in sorted(Path('.').rglob('package.json')):
if 'node_modules' in p.parts:
continue
try:
data=json.loads(p.read_text())
except Exception as e:
continue
if 'scripts' in data and any(k in data['scripts'] for k in {'dev','build'}):
print(p)
for k,v in data['scripts'].items():
if k in {'dev','build'}: print(' ',k,':',v)
PYRepository: mankatcheung/job-finder
Length of output: 5041
Use nanoid() for optimistic message identifiers.
tempMessageId() generates a numeric prefix plus Math.random(), but the guideline requires identifier strings to use nanoid(). Import nanoid from nanoid and use it for optimistic ids.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/web/src/routes/_authenticated/assistant/-components/ChatConversationView.tsx`
around lines 27 - 29, Update tempMessageId() to generate optimistic message
identifiers with nanoid(), importing nanoid from the nanoid package and
preserving the existing optimistic identifier prefix.
Source: Coding guidelines
| const handleSend = async (text: string) => { | ||
| const trimmed = text.trim(); | ||
| if (!trimmed || send.isPending) return; | ||
| setInput(''); | ||
|
|
||
| let targetConversationId = conversationId; | ||
| if (!targetConversationId) { | ||
| const created = await createConversation.mutateAsync({ | ||
| provider, | ||
| model: model.trim() || undefined, | ||
| }); | ||
| targetConversationId = created.createConversation.id; | ||
| qc.setQueryData<ConversationsResult>(conversationsQueryOptions.queryKey, (prev) => ({ | ||
| conversations: [created.createConversation, ...(prev?.conversations ?? [])], | ||
| })); | ||
| onConversationCreated(targetConversationId); | ||
| } | ||
|
|
||
| appendOptimistic(targetConversationId, { | ||
| id: tempMessageId(), | ||
| role: 'user', | ||
| content: trimmed, | ||
| createdAt: new Date().toISOString(), | ||
| }); | ||
| try { | ||
| const data = await send.mutateAsync({ | ||
| conversationId: targetConversationId, | ||
| message: trimmed, | ||
| }); | ||
| appendOptimistic(targetConversationId, { | ||
| id: tempMessageId(), | ||
| role: 'assistant', | ||
| content: data.sendChatMessage, | ||
| createdAt: new Date().toISOString(), | ||
| }); | ||
| // Refreshes the conversation's title (auto-derived server-side from | ||
| // the first message) and ordering (most-recently-updated first). | ||
| void qc.invalidateQueries({ queryKey: conversationsQueryOptions.queryKey }); | ||
| } catch { | ||
| // Error surfaced below via send.isError — the user's message stays visible so they can retry. | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Handle lazy-creation and send failures as failed optimistic operations.
If createConversation.mutateAsync() rejects, Line 110 has already cleared the draft and Lines 114-123 bypass the try block. Both callers discard the returned promise. The rejection is unhandled and send.isError remains false, so the user gets no error message.
If send.mutateAsync() rejects, the unpersisted user message remains styled as normal history. A manual retry then shows duplicate user messages until a history refetch removes the failed optimistic entry.
Catch both mutation paths. Keep or restore the draft when creation fails. Render a common mutation error. Roll back failed optimistic messages or mark them as failed with an explicit retry action. Add regression tests for both failure paths.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/web/src/routes/_authenticated/assistant/-components/ChatConversationView.tsx`
around lines 107 - 147, Update handleSend so conversation creation and message
sending are handled within a shared error path: preserve or restore the trimmed
draft when createConversation.mutateAsync fails, expose a common mutation error
for either failure, and ensure failed optimistic user messages are removed or
explicitly marked failed with a retry action instead of remaining normal
history. Update the component’s rendered error state and add regression tests
covering both createConversation and send mutation rejections.
Summary
Right now the AI assistant only exists as a full-page route (
/assistant) — leaving any other page loses the conversation from view entirely. This adds a persistent desktop footer, present on every logged-in page, that lets the user pop the assistant open as a floating chat widget without navigating away, minimize it back down without losing the conversation, keep several conversations pinned at once, and jump to the full/assistantpage for a conversation on demand.Desktop only (
lg:breakpoint, 1024px+) — mobile keeps its existing bottom-nav + full-page assistant flow unchanged.UX
document.body(same pattern as the notification inbox). Minimize collapses back to a pill without losing the conversation; Maximize navigates to/assistant?conversation=<id>and closes the dock section; Close unpins without deleting the conversation server-side.Technical approach
ChatConversationViewout ofAssistantPage.tsx— message list, optimistic send, the rotating "thinking…" state,AI_NOT_CONFIGUREDhandling — so the full page and the floating widget share one implementation instead of two that could drift apart. Both are now thin wrappers supplying their own header chrome and provider/model picker around the same core.ChatDockProvider(React Context, mirroring the existingThemeProvider— this app has no Redux/Zustand/Jotai). Holds only conversation ids, never conversation data itself — titles/messages stay in TanStack Query's cache, looked up by id wherever rendered, so dock state can't drift out of sync with the real data.AuthenticatedLayout, not__root.tsx(which has no auth awareness) — persists across in-app navigation within the authenticated route tree, doesn't exist pre-login./assistant/historyis a one-click fallback.ChatDockFloatingWindowreusesNotificationInboxPanel'screatePortal-to-document.bodypattern, but isn't a modal — no backdrop, no click-outside/Escape-to-close, since it's meant to sit alongside the page rather than block it.conversationsQueryOptions/chatHistoryQueryOptionscache keys as the full page, so switching between them is seamless with zero extra sync work.CHAT_HISTORY_QUERYto selectid/createdAt(already exposed byMessageType, previously unused) for stable React list keys.Decisions made during implementation (flagged as open in the Linear ticket)
/assistantitself — for a consistent mental model, rather than special-casing it away.sessionStoragepersistence in v1 — kept as the smallest thing that works; can be added later if the reload-loses-state behavior turns out to matter in practice.Fixes JEF-133.
Test plan
pnpm --filter @job-finder/web typecheckpnpm --filter @job-finder/web test(378/378 passed)pnpm --filter @job-finder/web lint(pre-existing unrelated warnings only)chatDock.test.tsx(12 tests, the state machine in isolation),ChatConversationView.test.tsx,ChatDockFooter.test.tsx,ChatDockFloatingWindow.test.tsx(mockinguseChatDockdirectly for precise state control)AssistantChatPage.test.tsxandAuthenticatedLayout.test.tsxtests pass unchanged after the extraction/mount🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Refactor
Tests