Skip to content

feat(jef-133): desktop chat dock — persistent footer + floating minimizable assistant widget - #325

Merged
mankatcheung merged 1 commit into
mainfrom
feat/jef-133-chat-dock
Aug 10, 2026
Merged

mankatcheung merged 1 commit into
mainfrom
feat/jef-133-chat-dock

Conversation

@mankatcheung

@mankatcheung mankatcheung commented Aug 10, 2026

Copy link
Copy Markdown
Owner

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 /assistant page for a conversation on demand.

Desktop only (lg: breakpoint, 1024px+) — mobile keeps its existing bottom-nav + full-page assistant flow unchanged.

UX

  • Footer bar, fixed to the bottom of every authenticated page. A launcher opens a compact picker popover ("New conversation" + recent conversations); picking one opens/expands it as the floating widget and pins it to the footer as a pill.
  • Floating widget: bottom-right, bounded panel, portaled to 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.
  • Only one widget is expanded at a time; any number can be minimized simultaneously in the footer rail.

Technical approach

  • Extracted 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 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.
  • New ChatDockProvider (React Context, mirroring the existing ThemeProvider — 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.
  • Mounted inside AuthenticatedLayout, not __root.tsx (which has no auth awareness) — persists across in-app navigation within the authenticated route tree, 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, and /assistant/history is a one-click fallback.
  • ChatDockFloatingWindow reuses NotificationInboxPanel's createPortal-to-document.body pattern, 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 and the floating widget read/write the exact same conversationsQueryOptions/chatHistoryQueryOptions cache keys as the full page, so switching between them is seamless with zero extra sync work.
  • Extended CHAT_HISTORY_QUERY to select id/createdAt (already exposed by MessageType, previously unused) for stable React list keys.

Decisions made during implementation (flagged as open in the Linear ticket)

  • 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, rather than special-casing it away.
  • No sessionStorage persistence 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 typecheck
  • pnpm --filter @job-finder/web test (378/378 passed)
  • pnpm --filter @job-finder/web lint (pre-existing unrelated warnings only)
  • New tests: chatDock.test.tsx (12 tests, the state machine in isolation), ChatConversationView.test.tsx, ChatDockFooter.test.tsx, ChatDockFloatingWindow.test.tsx (mocking useChatDock directly for precise state control)
  • All pre-existing AssistantChatPage.test.tsx and AuthenticatedLayout.test.tsx tests pass unchanged after the extraction/mount
  • Manually verified end-to-end in the browser against the seeded demo account: footer → picker → new conversation → send (verified both the message round-trip and the existing error-handling path) → minimize → pill persists across a real client-side route navigation (not just avoiding a page reload — actually clicked the sidebar nav link) → reopen from pill → maximize → full page shows the identical conversation history via the shared query cache

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a desktop chat dock for opening, minimising, closing and switching between conversations.
    • Added a conversation picker with recent chats, timestamps and provider labels.
    • Added floating chat windows with maximise controls and setup guidance when AI access is unavailable.
    • Added shared chat conversation views with suggested questions, lazy conversation creation, message sending and compact layouts.
    • Updated authenticated navigation with responsive desktop and mobile layouts.
  • Refactor

    • Unified assistant conversation rendering and interaction handling.
  • Tests

    • Added comprehensive coverage for chat conversations, dock behaviour and user interactions.

…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.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The 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.

Changes

Assistant chat flow

Layer / File(s) Summary
Shared conversation view and message contract
apps/web/src/routes/_authenticated/assistant/-components/ChatConversationView.tsx, apps/web/src/routes/_authenticated/assistant/-shared.ts, apps/web/src/__tests__/components/ChatConversationView.test.tsx, apps/web/src/__tests__/components/AssistantChatPage.test.tsx
ChatMessage now includes id and createdAt. ChatConversationView handles history, lazy creation, optimistic messages, sending, errors, suggestions, and compact rendering.
Assistant page integration
apps/web/src/routes/_authenticated/assistant/-components/AssistantPage.tsx
AssistantPage delegates conversation rendering and message handling to ChatConversationView.

Desktop chat dock

Layer / File(s) Summary
Chat dock state management
apps/web/src/lib/chatDock.tsx, apps/web/src/__tests__/lib/chatDock.test.tsx
ChatDockProvider and useChatDock manage pinned conversations, expanded conversations, drafts, minimising, closing, and promotion.
Chat dock controls and conversation windows
apps/web/src/routes/_authenticated/-chat-dock-picker.tsx, apps/web/src/routes/_authenticated/-chat-dock-footer.tsx, apps/web/src/routes/_authenticated/-chat-dock-floating-window.tsx, apps/web/src/__tests__/components/ChatDockFooter.test.tsx, apps/web/src/__tests__/components/ChatDockFloatingWindow.test.tsx
The picker, footer, and floating window support new and existing conversations, navigation, minimising, closing, maximising, and missing API-key prompts.
Authenticated layout integration
apps/web/src/routes/_authenticated/-components/AuthenticatedLayout.tsx
The authenticated layout now provides chat dock state and renders the desktop footer and floating window. It also adds mobile bottom navigation and adjusts content spacing.

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
Loading

Possibly related issues

Possibly related PRs

Poem

A rabbit found chats beneath the floor,
Pinned them neatly by the door.
New thoughts hop into view,
Old replies keep timestamps too.
The dock now opens, hides, and sings,
While carrots power assistant things.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 44.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: a desktop chat dock with a persistent footer and minimisable assistant widget.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/jef-133-chat-dock

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
apps/web/src/routes/_authenticated/-chat-dock-floating-window.tsx (1)

25-34: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Defer dock queries until the dock needs their data.

AuthenticatedLayout mounts 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 Query enabled options to fetch only when dock.expanded is set or dock.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

📥 Commits

Reviewing files that changed from the base of the PR and between 3feb87a and 212d00d.

📒 Files selected for processing (13)
  • apps/web/src/__tests__/components/AssistantChatPage.test.tsx
  • apps/web/src/__tests__/components/ChatConversationView.test.tsx
  • apps/web/src/__tests__/components/ChatDockFloatingWindow.test.tsx
  • apps/web/src/__tests__/components/ChatDockFooter.test.tsx
  • apps/web/src/__tests__/lib/chatDock.test.tsx
  • apps/web/src/lib/chatDock.tsx
  • apps/web/src/routes/_authenticated/-chat-dock-floating-window.tsx
  • apps/web/src/routes/_authenticated/-chat-dock-footer.tsx
  • apps/web/src/routes/_authenticated/-chat-dock-picker.tsx
  • apps/web/src/routes/_authenticated/-components/AuthenticatedLayout.tsx
  • apps/web/src/routes/_authenticated/assistant/-components/AssistantPage.tsx
  • apps/web/src/routes/_authenticated/assistant/-components/ChatConversationView.tsx
  • apps/web/src/routes/_authenticated/assistant/-shared.ts

Comment on lines +96 to +102
<ChatConversationView
conversationId={isNew ? null : (dock.expanded as string)}
provider={defaultProvider}
model=""
onConversationCreated={(id) => dock.promoteNewConversation(id)}
compact
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

Comment on lines +52 to +75
<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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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

Comment on lines +27 to +29
function tempMessageId(): string {
return `optimistic-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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/src

Repository: 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)
PY

Repository: 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

Comment on lines +107 to +147
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.
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.

@mankatcheung
mankatcheung merged commit 35d4320 into main Aug 10, 2026
11 checks passed
@mankatcheung
mankatcheung deleted the feat/jef-133-chat-dock branch August 18, 2026 13:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant