Skip to content

One conversation surface: retire the chat half (#206) - #318

Merged
kiranfin merged 24 commits into
devfrom
feature/206-one-surface
Oct 7, 2026
Merged

kiranfin merged 24 commits into
devfrom
feature/206-one-surface

Conversation

@daniilperkin

@daniilperkin daniilperkin commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Draft — the merge of /chat and /buddy into 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 /chat retires. Nothing migrates — the backfill was dropped together with the chat tables (SprintStartProject/sprintstart-backend#283), so the redirects are id-free.

Commits

  1. b4b1aa63 — decouple: the mascot trio (SleepyBot/BotGlyph/CopyButton), useIdleSleep and the format/quote helpers move out of features/chatbot into features/buddy; the dock, launcher, reply actions, dashboard widgets and selection toolbar are re-pointed. Buddy messages gain the data-message-role marker the selection Reply flow reads — they never carried one, so the offer would have died with the chat.
  2. 369d1292 — the unified page: /buddy/:id end 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.
  3. f8a6a9dd — the teardown: /chat and /chat/:id replace-redirect to /buddy; AssistantShell, AssistantSurfaceSwitch, assistantSurfaces and ChatPage are deleted; the sidebar entry becomes "Buddy"; nav-chat (Alt+C) is removed; prefetch, page-transition keys and the app-guide list follow.
  4. d000e052 — a11y mounts: the dashboard and knowledge-base page suites now mount the providers the app does (the widget reads the buddy draft), and BuddyPage.a11y drops its nested <main>.
  5. 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 a SidePanel drawer; "unseen" is per-user localStorage ids (no server-side read state yet).
  6. eadf564b — composer & session: stop (AbortSignal → the turn closes as cut short, the state the backend persists for a cancelled turn), the message queue (ported from ChatProvider — 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.
  7. e6fd0c47 — the sweep: features/chatbot, ChatProvider/ChatContext, chatService and their suites deleted; chatLink/chatMessageNote removed; the dead .chat-md/.streaming-caret styles dropped; stale ChatPage/chatService comment references updated.

Gates

  • npm run try green locally, end to end: format:check, build, lint, 395 unit files / 4087 tests, 53 a11y files / 95 tests.
  • CI (build (24) + gitleaks) runs on this PR.

Notes for review

  • No new tests were added on this branch (per instruction); the suites for moved code moved with it (useAvailableSources, ReasoningPanel), and suites of deleted code were deleted. Queue / filters / switch / stop / panel behaviors have no new coverage yet — follow-up.
  • The AI-service counterpart is Remove the chat surface from the AI service sprintstart-ai#218.

Cross-repo

Frontend is the frontier of the retirement chain. /chat redirects 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).

  1. 80b7aa1e — citation drawer project: citationDrawerProjectId read 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.
  2. e80b9213 — /chat/:id keeps 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.
  3. 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 drops draftResetToken — the conversation key is the signal.
  4. b8a13066 — turn resilience: an inter-event 5-minute watchdog fails a silent stream (closes like a Stop, says it timed out); retryReply re-asks a failed turn's question through submitMessage; the session list is re-read after errors too, not only on done.
  5. 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.
  6. 234d2bfd — surfaces: the dino outcome reads the message's stopped flag 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 on 234d2bfd.

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

…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).
@daniilperkin

Copy link
Copy Markdown
Collaborator Author

@kiranfin — this is the frontend half of the /chat retirement (#206); handing it over for review.

What needs to happen to merge

  • Review + one approval — the ruleset wants 1 approval and all threads resolved; CI is already green (build (24) + gitleaks).
  • Flip it out of draft when you start (it's a draft on purpose).
  • Review focus, by risk:
    1. BuddyPage.tsx — the single URL↔session reconciler (a deep link opens; an unknown id → /buddy; a rail pick rewrites the address). One effect with explicit precedence — the old two-effect version ping-ponged.
    2. Redirects — /chat and /chat/:id → /buddy, id-free by design: the backfill was dropped with the chat tables (backend#283), so nothing may depend on a chat id.3. Queue/stop — a submit mid-turn queues; Stop aborts the fetch and closes the turn as cut short (isIncomplete), matching the backend's cancelled-turn state, and pauses the queue until "Send queued".
    3. Filters — wire shape source_systems/time_from/time_to, sent absent (not empty) when nothing is set.
    4. Mentor switch — per-user localStorage, default ON.

Known gaps (follow-ups, not merge blockers)

  • No new tests for the new behaviors (queue, filters, switch, stop, reasoning panel) — deliberate; coverage is a follow-up.
  • The PM replies drawer's "unseen" state is per-user localStorage only; server-side read state is a follow-up.

Chain: this PR is the frontier — merge order is FE#206 → uni#10 → backend#283 → ai#218; the rest stay blocked until this lands.

@kiranfin

kiranfin commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

yes, it is complicated, there are a bit more bugs. But i'll fix them i guess

kiranfin and others added 5 commits October 6, 2026 21:50
- 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.
@daniilperkin

Copy link
Copy Markdown
Collaborator Author

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

  • 943f0ab4 — Stop and the queue arm from the start of a turn (turnActive; chat parity — they used to wait for the first token, so a thinking or tool-stuck turn had no Stop); releasing a Stop-held queue no longer starts a second stream beside a running turn; a send that cannot find its conversation no longer wedges the queue behind a turn that never ends; the active-filter chips now show in the dock too (they were page-only while BuddyFiltersButton's doc promised otherwise); plus comment corrections and a dead export dropped.
  • a08ddf3a — tests pinning all of the above (both queue cases verified red with their fix reverted), plus the mentor switch and the filters on the wire (local day → UTC instants).
  • e5a23322 — lint fixup (an async act around a sync body trips require-await).

CI green on e5a23322 (build (24) + gitleaks). The frontend side of the chain is ready for review.

@daniilperkin
daniilperkin marked this pull request as ready for review October 6, 2026 21:09
@daniilperkin daniilperkin linked an issue Oct 6, 2026 that may be closed by this pull request
13 tasks
kiranfin and others added 12 commits October 6, 2026 23:49
- 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
@kiranfin
kiranfin merged commit 6fa5644 into dev Oct 7, 2026
4 checks passed
@kiranfin
kiranfin deleted the feature/206-one-surface branch October 7, 2026 00:44
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.

One conversation surface: retire /chat

2 participants