Repository navigation
One conversation surface: retire the chat half (#206) - #318
Conversation
…hat feature The SleepyBot/BotGlyph/CopyButton characters, the idle-sleep and pointer-gaze hooks, the shared date formatters and the markdown quote formatter were filed under `features/chatbot/` only because the chat was the first place they were used. Every consumer left in the app is a buddy surface — the dock, the launcher, the message avatars, the dashboard widgets — so they move to `features/buddy/` ahead of the chat teardown, history and tests included. The four consumers that read chat state directly are re-pointed to the buddy's own machinery rather than waiting for the teardown to break them: - QuickChatWidget seeds the shared buddy draft and routes to /buddy; the words wait in the composer, unsent, wherever the buddy is showing it. - RecentChatsWidget reads the hire's conversation list from its own endpoint (new `queryKeys.buddy.sessions` entry) instead of the app-root chat list, which is project-scoped and dies with the chat. - useAskAi seeds the buddy composer and routes to /buddy; it no longer needs a chat context to answer whether it can hand a prompt over. - SelectionActions' Reply quote goes through the buddy bus into the buddy's composer — same paragraph-preserving quote format, moved to `features/buddy/utils/quoteFormat`. `selectionCapture` marked answers via `data-chat-message-role`, which only the chat's MessageRow ever set — the buddy thread now carries the marker too (renamed to the neutral `data-message-role`), so the Reply offer survives the chat's removal instead of quietly disappearing with it.
…ject gate The unified surface needs one thing the two-surface arrangement never had: an address for a conversation. `/buddy/:id` now exists — route, access-policy prefix, page-transition key — and the dock's expand hands over to the conversation on screen instead of the bare route, which is what the hand-off was always meant to land on. The page reconciles URL and session in both directions through one effect, because two would fight: a rail pick moves the conversation while the URL still names where it was. A URL that names neither where we are nor where we just were is an explicit address — a deep link, a reload, the back button — and wins; it opens when the conversation list knows it and falls back to the bare page when it does not, since a stale link is not a failed read and the error banner's retry would only fail again. Otherwise a conversation that changed — a rail pick, a bin of the current row, a new conversation — takes the address with it. The first resolve is left out on purpose: the bare page keeps its bare address. `BuddyWidget`'s page checks moved from exact match to `startsWith` — without that the floating dock would have rendered over every `/buddy/:id` session page, launcher and all. The rail gains what the chat's sidebar had (folded in from #266): title search and date-bucket grouping, client-side over the list the page already holds, with an empty-match state. And the no-project gate is gone. The hire's buddy is not one project's, and a dead end for everyone redirected here from the retired `/chat` is exactly what the merge is not allowed to leave behind — the page opens the conversation for them like for anyone else. Tests: the page harness carries the new per-conversation route; the hand-off stub matches it; the no-project case asserts the conversation instead of the empty state; the composer-isolation counts stay exact because the address is deliberately not renamed on first resolve.
The merge's structural half: the shell that carried two halves is gone, and with it everything that existed only to switch between them. - AppRouter: `/chat` and `/chat/:id` become replace-redirects to `/buddy`; the `AssistantShell` layout route is deleted and `/buddy` + `/buddy/:id` render the page directly. The id is dropped on the redirect because nothing migrated — the backfill went the way of the chat tables it fed (backend #260/#283) — so no old link can map to a conversation that still exists. - BuddyPage draws its own header and frame now: the same viewport panel the shell owned, with a `PageHeader` titled "Buddy". The no-project gate is already gone, and the Alt+N gate on being the visible half went with it — one surface later, the page is only ever mounted at its own addresses. - SideBar: the "Chat" entry becomes "Buddy" → `/buddy`; the forced highlight goes, because `NavLink` lights the entry for the bare route and child addresses on its own. Shortcuts: `nav-chat` (Alt+C) is removed, Alt+U stays, and the composer row's note now reads "on the Buddy page". - routePrefetch warms the buddy page for the sidebar entry; PageTransition keys every `/buddy` address as one group, so switching conversations never remounts the page under its own state; appGuideRoutes moves `/chat` to the excluded list with its reason. It stays in the access policy as a redirect — the policy knows every URL the app serves, and the redirect tests rely on that. - Deleted with their subjects: ChatPage, AssistantShell, AssistantSurfaceSwitch, assistantSurfaces, and their five test files. - ChatProvider leaves the App tree (the buddy session already wraps the app). Its files stay for the composer-port commit, then die with the rest of `features/chatbot` — this commit is deliberately structural-only so it reviews as one story. Tests: the page harness renders the page directly; the sidebar suite asserts the Buddy entry lights on a conversation address; the shortcut suites drop Alt+C; the dashboard suite mounts the buddy provider its widgets now reach for.
Three a11y suites fell out of the teardown, each for a real reason the merge introduced: - BuddyPage draws its own `<main>` now that the shell retired, so the harness must not wrap it in a second one — two nested main landmarks are exactly the violation this suite exists to catch. The no-project wait moved to the chips above the composer, because that state is the same conversation now (the gate is gone), and the chips prove the interactive page settled. - The dashboard and knowledge-base pages reach for the app's one buddy session (draft seeding from the quick-ask widget, the recent-conversations card, the citation drawer's ask action), so their harnesses mount `BuddyProvider` the way the app does. It is inert until a surface asks for the conversation.
The rail carried two things — the hire's conversations and their PM's replies — and it was never a good fit for the second: the conversations were capped at 45% to leave the replies room, and a hire with one conversation and no replies could not open the rail at all, because the auto-open that surfaced an answer was also the only reason it mounted. Now the replies have the drawer the merge planned for them: - A header button, "Sent to your PM", opens a `SidePanel` with the three groups (answered / waiting / dismissed). The button is the signal: it carries a badge counting answers not looked at yet, so the promise `FlagToPmButton` makes no longer depends on the hire finding the right control. - "Unread" is per user and lives in localStorage — opening the drawer marks everything answered so far as seen, so the badge answers "is there something I have not looked at?" rather than "has a PM ever answered me?". There is no read state server-side to use, and ids (not a count) keep yesterday's read from hiding today's answer. - The rail is conversations-only: it mounts when there are two, the list is no longer capped, and nothing opens it on the hire's behalf — a drawer nobody asked for would be an overlay on a page they may be reading. The toggle loses its count; that signal lives on the header button now. - `BuddyPmReplies` loses its own heading (the drawer's title says it) and its docstring now describes where it lives. Tests: the four rail cases whose premise was the auto-open now open the rail the way a hire does; the narrow-screen case asserts the header button and the drawer it opens.
… panel The composer and session learn the rest of what the chat surface could do, so the one remaining conversation loses nothing by the chat's retirement (issue #206's "absorb everything" list). Stop: `streamMessage` takes an AbortSignal and `readBuddyStream` resolves an abort silently — fetch and the reader both reject with AbortError, told apart from a failure by name (a DOMException is not an Error subclass everywhere). The turn then closes as "cut short" (`isIncomplete`, the state the backend persists for a cancelled turn) rather than failed, so a reload agrees with the screen and no error line appears for a stop the hire asked for. Queue: messages submitted while a turn runs wait in the session, oldest first, and drain from the send path's terminal `finally`. Stop holds the queue back — it means "stop doing things", not "throw away what I wrote" — and the strip offers a "Send queued" release. The strip and the queue's semantics are ported from ChatProvider/QueuedMessages unchanged; the state lives in the session because the dock and the page share one composer. A session switch drops the queue the same way it resets the draft: the words belonged to the conversation that just went away. Filters: the source/date filter UI, the available-sources hook and the chips strip move from features/chatbot into the buddy feature, and the session sends them per message in the wire shape the backend's `BuddySessionFilters` names (`source_systems`, `time_from`, `time_to`). An inverted range is flagged in the popover and refused on submit. Mentor switch: `capabilitiesEnabled` rides on every message (the backend defaults true), persisted per user — default on, only an explicit off is remembered. "Actions" stays visible in both layouts: it is a mood, not a setting, and a hire should be able to see which state they left it in. Thought panel: the reasoning events the backend already emits are parsed, kept on the message, and rendered through the ported ReasoningPanel (KaTeX stripped, markdown through the buddy's own renderer) — capped, self-folding while the answer is being written, and reader-controlled once touched. Stop and Send coexist while an answer is written: Send queues the follow-up instead of being replaced, which is what makes the queue reachable at all. Targeted suites green (45 files, 331 tests); tsc -b and prettier clean.
The chat feature is gone as a feature; this removes its last remains so nothing half-dead points at it: - `features/chatbot/` (12 files), `ChatProvider`/`ChatContext`, `chatService` and their suites. What survived had already moved — the mascot trio, the draft and queue semantics, the filter UI and the reasoning panel now live under `features/buddy`; the two suites for moved code (useAvailableSources, ReasoningPanel) moved with it. - `chatLink`/`chatMessageNote` in `chatToCard` — their only callers were the chat page — so the module now serves the buddy's transcript notes alone. - the `.chat-md`/`.streaming-caret` stylesheet blocks, which have no remaining users. - stale "see ChatPage"/"chatService" comment references in `sse`, `ToastContext`, `MatrixRain`, the dino hook, egg phrases and `BuddyPage`. Also folds in the two lint findings the full gate surfaced on the new code: `isFilterRangeInvalid` moves to `utils/filterRange` (the fast-refresh rule wants component files to export components only), and the mentor-switch read defers its setState to a microtask, the shape the team-mode read already uses. `npm run try` green end to end: format:check, build, lint, 395 unit files (4087 tests), 53 a11y files (95 tests).
|
@kiranfin — this is the frontend half of the What needs to happen to merge
Known gaps (follow-ups, not merge blockers)
Chain: this PR is the frontier — merge order is FE#206 → uni#10 → backend#283 → ai#218; the rest stay blocked until this lands. |
|
yes, it is complicated, there are a bit more bugs. But i'll fix them i guess |
- Move toWireFilters into filterRange.ts and convert dates to UTC instants for the local day start and end - Stop clamping the end bound to now so the chosen "to" day is fully included - Add unit tests for sources, dates, summer and winter time, and invalid input
- Cover queueing during a running turn, ordered draining and a queue that survives a failed answer - Cover Stop (abort, cut-short marker, held queue) and resumeQueue, removeQueued and pullQueuedMessage - Cover that a new conversation or a session switch clears the queue and the hold
Five findings from re-reading the new composer and queue surface, each pinned in the test commit that follows: - **Stop and the queue arm from the start of a turn.** They waited for the first token or thought, so the thinking window — a tool that hangs included — had no Stop, and a follow-up typed into it read as a plain Send while actually queueing. `BuddyConversation` gains `turnActive` — the page's raw "a message turn is in flight", distinct from the combined `isThinking` that drives the typing dots — and the page and dock pass `isThinking || isStreaming`, the state the retired chat surface's `isBusy` armed. `onReasoning` no longer claims to be what arms Stop; it still marks the thread live. - **Releasing a Stop-held queue no longer races a running turn.** After Stop, a message sent goes straight out while the hold stays; asking for the queue during that turn started a second stream beside it — interleaved answers and one orphaned abort handle. The release now sets the flag only and the running turn's close-out drains, in order. - **A send that cannot find its conversation no longer wedges the queue.** The early return on a failed session resolution left the in-flight markers set, so every later submit queued behind a turn that would never end (and only a closing turn drains). The failure path releases the markers and hands a queued message its turn. - **Active filters show in the dock too.** The chips were gated on the page layout while `BuddyFiltersButton`'s doc promised they "still show what is active" in the dock — the dock sends with the same session filters, so a narrowed search was invisible exactly where the promise was made. Only the popover stays a page affair. - **The inverted-range gate says what it guards.** The comment claimed the backend validates the range; nothing downstream does, so the gate is what keeps an inverted window from silently matching nothing. Also drops the session's `clearFilters` — exported, never consumed.
…e message options - `BuddyConversation.test.tsx`: Stop and "Queue message" from the first moment of a turn, a plain Send and no Stop while none runs. - `BuddyComposer.test.tsx`: the active-filter chips in the compact dock, and no popover there. - `useBuddyConversation.queue.test.tsx`, two cases for the fixes above: releasing the hold mid-turn waits its turn (red with the guard removed — verified), and a failed session resolution does not wedge the queue (likewise red without the reset). - `useBuddyConversation.messageOptions.test.tsx` (new): the mentor switch — on by default, an explicit off remembered per user and sent — and the filters reaching the wire as the local day's UTC instants, or absent when none are set.
The send helper wrapped a synchronous body in an async `act`, which trips `@typescript-eslint/require-await` — CI gates lint errors, so the push went red on it. The send now goes through the same sync `act` + `waitFor` the queue suite uses, which is all the assertion needs.
|
@kiranfin — verified your two commits (both sound: the AI contract wants ISO-8601 timestamps — the old raw dates were read as server-local midnight — and the queue/stop suite passes TZ-independent; CI green on both) and then closed the gaps a deep pass over the surface found, on top of them:
CI green on |
- Keep stopped turns visible with their own "You stopped this reply" marker, and join reasoning thoughts as paragraphs - Keep the address and the conversation on screen in step: open deep links directly, re-read the list once before dropping an unknown id, and put the address back instead of switching later mid-turn - Stop opening the hidden dock when a seed arrives on the buddy page, restore the `/` composer shortcut, and add tests for redirects, rail search and the PM badge
- Replace the native mode select with FilterSelect and move it into the page header; use the sidebar's MessageSquare icon for the header - Always show the conversations rail toggle and the new-conversation button (the latter disabled when starting one isn't possible) - Update BuddyPage and BuddyModeSwitcher tests accordingly
A PM managing several projects reads a team conversation while the global project selection sits on something else, clicks a [N] citation, and the drawer reads the source from the wrong project. `citationDrawerProjectId` returned the session's project first — but in team mode the session behind the switch is the hire's own conversation, so "the conversation's project" was the wrong conversation. The managed project is what the surface is actually about: it is the project the mentor switch picked and the one the team service searches, so it now wins — `teamProjectId ?? sessionProjectId ?? selected`. A hire conversation without a team project behaves exactly as before, and the tests pin both orders so a future reorder cannot quietly regress the team case again.
Old "Keep this chat" cards and bookmarks link to /chat/<id>, and the merge redirected them to the bare /buddy page — on the assumption that nothing migrated, so no chat id could name a conversation that still exists. That assumption is wrong: the backfill that ran before the chat tables were dropped copied each chat's id onto the buddy session it created (`BuddySession(id = chat.id)` in BuddyChatBackfillService), so the id does name a conversation — and dropping it threw away the only part of the link that meant anything. The redirect now keeps the id and opens the conversation. A link whose id has no conversation behind it — an environment the backfill never reached, a binned thread — still lands on the bare page: BuddyPage's reconciler treats an unknown id as "show the conversation list", so a stale link degrades quietly instead of failing loudly.
A question typed about one conversation followed the hire into the next one — switching threads or projects kept the text, because the one shared box only knew "the draft", never whose it was. The retired chat filed every draft under its conversation; this brings that back. Each draft is now keyed on the conversation it belongs to — the session id, `team:<projectId>` for a team conversation, `__new__` for the window before the first open resolves one — stored as `buddyDraft.<key>`, saved when the hire switches away, debounced 400 ms while typing (a reload lands on the same words), read back when that conversation returns, and cleared on send both immediately and through the debounce, so a reload right after sending cannot resurrect an already-asked question. The swap rides the conversation key rather than a reset signal from the session: the key *is* the conversation, so nothing has to be kept in step — which is what lets the hook drop `draftResetToken` and the whole told-not-pulled dance with it. Words typed before the first open resolved belong to the conversation that then appears; they were a question about that thread, not about a box that never existed.
…e a turn that never finished Three ways a turn could end badly and not say so, all in the send loop: - A stream that stopped arriving held the request open forever — the read has no timeout of its own, so the hire watched a typing row that would never resolve. The turn now runs the retired chat's inter-event watchdog: five silent minutes — measured between events, re-armed by every tool, thought, token, citation and proposal, so a long tool-heavy turn keeps talking — abort the stream, and the close-out fails the reply with a line that says it went quiet instead of calling it the hire's own Stop. - A failed turn left no way to ask again without retyping. `retryReply` reads the question back out of the thread and re-submits it through the composer's own entry point — idle it sends, mid-turn it queues, exactly as a typed question would — so the re-ask needs no new path through the queue logic, and the failed pair stays visible in the thread. - The backend writes the title before the turn runs, but the list was only re-read on `onDone`, so a failed or stopped first turn left the rail reading "New conversation" until the next turn. The error path now re-reads like `markStopped` already did. A timeout closes like a Stop — busy states cleared, tool label gone — but marks the reply failed; `failTimedOut` keeps the two endings from drifting apart. (The resilience suite caught the drift once: the first cut of the timeout failed the reply but left the stream "running".)
…nce the queue Two composer affordances the chat had and the merge dropped: - ArrowUp/ArrowDown walk every question this conversation has seen — newest recalled first, oldest last, read off the rendered thread rather than a store of its own, so the walk survives a reload and always matches what is on screen. The index only counts while the box still holds the words it recalled: edit a recalled question and the walk is over, which is what keeps ArrowUp from eating a draft that happens to start with an older question. Reaching the oldest stops there rather than wrapping; stepping past the newest hands the box back empty. The retired chat pinned these behaviours in ChatComposer.history.test; the ported suite keeps the eight of them honest. - The queue strip appearing is visual only; "Message queued" now announces it for screen readers. Derived during render from the queue length (the documented adjust-state pattern) and cleared when the queue shrinks, so the next queue-up is a fresh text change — the only thing a live region announces.
…st message The surfaces' half of the turn-lifecycle work, plus two pieces of thread chrome: - A stopped turn now reads as stopped everywhere. The dino's outcome comes from the message's own `stopped` flag instead of defaulting to "done" — the game's row outlives the turn while the game is open, which is exactly how a stopped turn used to reach it and get announced as "Reply ready". The thread's completion line says "Reply stopped" / "Reply failed" / "Response complete" once per turn, and stays silent while the game is open: the game announces its own outcome there, and two voices would be noise. Held in render state so the next turn clears it — a live region only announces a change. - The failed reply's error line carries "Try again", rendered only when the turn has a question behind it (a greeting has none) and wired through the page and the dock to the session's `retryReply`. - Scrolling up to re-read something released the auto-scroll pin with no way back down. Both transcripts now offer a floating "Jump to latest" while unpinned. The pin gains a state mirror because a ref cannot re-render the button that appears when it releases; the jump takes the reader down and takes the pin back. The page also derives the composer's prompt history from the rendered thread and passes it down, completing the arrow-key walk's wiring.
- Bring the queue, retry, history, draft and announcement work into the header redesign
…d scope drafts per user - Handle the reset event, append reasoning deltas as they come, batch tokens per frame and label search_docs and grep - Make switching, starting and binning a conversation stop a running answer instead of being blocked, and drop the queue with it - File drafts under the user, sweep unscoped ones, and forget a conversation's draft when it is binned
- Load filterable sources from the project's artifact facets instead of the PM/admin-only connectors endpoint - Pass the selected project id into the hook and rewrite its tests for the facet data
- Put a "New conversation" button at the top of the conversation list, greyed out while starting one is not possible - Show the floating button only while the rail is closed so one screen never carries two - Reserve the floating controls' room from the rail state alone
Draft — the merge of
/chatand/buddyinto one surface (#206), implemented end to end on this branch.What this does
One conversation surface: the buddy conversation page absorbs everything the chat page did, and
/chatretires. Nothing migrates — the backfill was dropped together with the chat tables (SprintStartProject/sprintstart-backend#283), so the redirects are id-free.Commits
b4b1aa63— decouple: the mascot trio (SleepyBot/BotGlyph/CopyButton),useIdleSleepand the format/quote helpers move out offeatures/chatbotintofeatures/buddy; the dock, launcher, reply actions, dashboard widgets and selection toolbar are re-pointed. Buddy messages gain thedata-message-rolemarker the selection Reply flow reads — they never carried one, so the offer would have died with the chat.369d1292— the unified page:/buddy/:idend to end (route, access-policy prefix, transition key, dock hand-off); the page reconciles URL and conversation in one effect with explicit precedence — deep links open, an unknown id falls back to/buddy, a rail pick takes the address with it; the rail gains title search and date grouping (folded in from Buddy parity with chat: citations, stop, queue, reasoning, searchable rail, re-point chat consumers #266); the no-project gate is removed.f8a6a9dd— the teardown:/chatand/chat/:idreplace-redirect to/buddy;AssistantShell,AssistantSurfaceSwitch,assistantSurfacesandChatPageare deleted; the sidebar entry becomes "Buddy";nav-chat(Alt+C) is removed; prefetch, page-transition keys and the app-guide list follow.d000e052— a11y mounts: the dashboard and knowledge-base page suites now mount the providers the app does (the widget reads the buddy draft), andBuddyPage.a11ydrops its nested<main>.8be81dbb— PM replies drawer: what came back from the PM moves out of the rail into a page-header button with an unseen badge and aSidePaneldrawer; "unseen" is per-user localStorage ids (no server-side read state yet).eadf564b— composer & session: stop (AbortSignal → the turn closes as cut short, the state the backend persists for a cancelled turn), the message queue (ported fromChatProvider— stop holds it, "Send queued" releases it), source/date filters + the available-sources hook (wire shape:source_systems/time_from/time_to), the per-user mentor "Actions" switch (capabilitiesEnabled, default on), and the reasoning panel (KaTeX stripped, rendered through the buddy's markdown). Stop and Send coexist: Send queues the follow-up instead of cutting the answer off.e6fd0c47— the sweep:features/chatbot,ChatProvider/ChatContext,chatServiceand their suites deleted;chatLink/chatMessageNoteremoved; the dead.chat-md/.streaming-caretstyles dropped; staleChatPage/chatServicecomment references updated.Gates
npm run trygreen locally, end to end: format:check, build, lint, 395 unit files / 4087 tests, 53 a11y files / 95 tests.Notes for review
useAvailableSources,ReasoningPanel), and suites of deleted code were deleted. Queue / filters / switch / stop / panel behaviors have no new coverage yet — follow-up.Cross-repo
Frontend is the frontier of the retirement chain.
/chatredirects are id-free deliberately: the backfill was removed together with the chat tables (SprintStartProject/sprintstart-backend#283), so no old link can map to a conversation that still exists.Review round (round 1 fixes,
80b7aa1e..234d2bfd)A deep review pass over the merged surface produced 16 findings; the frontend fixes are on this branch now, rebased onto kiranfin's
c0c5b054(adopted first — where it already reworked stop semantics, address sync and dock seeding, its shape was kept and the review work re-applied selectively on top).80b7aa1e— citation drawer project:citationDrawerProjectIdread the hire session's project first; in team mode that session is the conversation behind the switch, so the drawer opened in the wrong project for a PM on several. The managed team project now wins.e80b9213—/chat/:idkeeps its id: the id-free redirect assumed nothing migrated — but the backfill copied each chat's id onto the buddy session it created (BuddyChatBackfillService,id = chat.id), so the id names a conversation that exists. This supersedes the "id-free redirects" decision in the sections above.b03252a4— per-conversation drafts: each draft is filed under its conversation (buddyDraft.<sessionId>/buddyDraft.team:<projectId>/__new__), saved on switch, debounced while typing, cleared on send; the hook dropsdraftResetToken— the conversation key is the signal.b8a13066— turn resilience: an inter-event 5-minute watchdog fails a silent stream (closes like a Stop, says it timed out);retryReplyre-asks a failed turn's question throughsubmitMessage; the session list is re-read after errors too, not only on done.ed14378c— composer: ArrowUp/ArrowDown walk the conversation's questions (ported from the retired chat, its eight pinned behaviours re-pinned); "Message queued" announced for screen readers.234d2bfd— surfaces: the dino outcome reads the message'sstoppedflag instead of announcing "Reply ready" for a stopped turn; thread completion announcements ("Response complete" / "Reply failed" / "Reply stopped", silent while the game is open); a "Try again" button on failed replies; a floating "Jump to latest" on both transcripts while scrolled up.Correction to the notes above: new tests DID land with this round — 5 new suites (
useBuddyConversation.resilience,BuddyComposer.history,buddyDraftPerConversation,buddyJumpToLatest,buddyAnnouncements) plus updates to 5 existing ones. The buddy + router + page suites (484 tests) run green locally; format:check, build and lint are green locally; CI (push + pull_request) is green on234d2bfd.Deliberately unchanged: switching conversation mid-turn stays refused — a stream cannot call its callbacks into a cleared thread (documented in the hook).
Still open elsewhere (not frontend):
request.filters—BuddyController's team branch never passes them andBuddyTeamService.sendMessageForMehas no filters parameter, so the filter chips are silently ignored in team mode (the hire path passes them; the AI applies them in any mode).ChatTablesDropperstill drops the chat tables on every startup while the backfill that fed Buddy was deleted in the same PR — confirm the backfill ran in every environment (prod included) before this merges.