Skip to content

feat(chat): unify provider workflows and improve transcript performance - #1206

Open
blackmammoth wants to merge 98 commits into
mainfrom
perf/chat-and-project-loading
Open

feat(chat): unify provider workflows and improve transcript performance#1206
blackmammoth wants to merge 98 commits into
mainfrom
perf/chat-and-project-loading

Conversation

@blackmammoth

@blackmammoth blackmammoth commented Aug 25, 2026

Copy link
Copy Markdown
Member

Closes #1214
Closes #1209
Closes #1208
Closes #1203
Closes #1201
Closes #1186
Closes #1182
Closes #1100
Closes #1010
Closes #922
Closes #813
Closes #554
Closes #409

Summary

This PR brings Claude and Codex conversations onto a shared chat model, expands conversation workflows across both providers, and substantially improves performance for large transcripts and project lists.

What changed

Unified provider experience

  • Normalized Claude and Codex transcripts into a shared message and tool model.
  • Unified rendering for subagents, plans, checklists, user questions, tool calls, and memory citations.
  • Added clearer fallback rendering for previously unmapped tools.
  • Added consistent edit statistics for file changes.

Conversation workflows

  • Added message editing and re-running for Claude and Codex.
  • Added independent conversation forking for both providers.
  • Added scheduled messages.
  • Added document-style Markdown and HTML conversation exports.
  • Kept optimistic edited messages visible while provider rewinds complete.

Persistence and synchronization

  • Moved user preferences and chat drafts from browser-only storage into auth.db.
  • Added database-backed scheduled messages.
  • Scoped drafts to individual sessions and synchronized them across devices.
  • Centralized session update broadcasts and improved multi-tab live-stream delivery.
  • Removed indexed sessions whose transcript files no longer exist.

Performance

  • Cached parsed transcript history, reducing warm history requests from roughly one second to single-digit milliseconds on large files.
  • Lazily mounted transcript rows, reducing a 29k-row fixture from roughly 1 GB to about 112 MB.
  • Coalesced concurrent provider scans and prevented duplicate project loading during mount.
  • Reduced representative history payloads from 11.6 MB to 2.5 MB.
  • Reduced sidebar DOM nodes from 1,064 to 729.
  • Reduced the bundle from 3,345 kB to 2,871 kB.
  • Made React Scan opt-in and avoided loading unnecessary syntax grammars and Markdown plugins.

Reliability fixes

  • Preserved live streams across multiple subscribed tabs.
  • Prevented deferred scrolling from overriding user navigation.
  • Fixed stale Claude token usage and refresh-token authentication handling.
  • Surfaced shell startup errors instead of silently dropping them.
  • Hardened Codex edit/fork handling around concurrent runs, failed rewinds, transcript cleanup, optimistic messages, and token usage.

Database changes

Adds migrations and repositories for:

  • User preferences
  • Session drafts
  • Scheduled messages
  • Superseded provider transcripts

Existing sessions and preferences are preserved through the migration path.

Testing

Added focused unit and integration coverage for:

  • Claude and Codex transcript normalization
  • Message editing and conversation forking
  • Session history caching and scan coalescing
  • Scheduled messages
  • Preferences and draft persistence
  • WebSocket fan-out and session broadcasts
  • Lazy transcript rendering and scroll ownership
  • Conversation exports and diff statistics
  • Orphaned-session cleanup and provider authentication

Large real-world transcripts and live provider flows were also used to validate the main performance and conversation workflows.

Subscribe TaskMaster directly to websocket events so rapid broadcasts are handled individually without forcing every frame through shared React state.
Separate changing processing-session state from stable actions so consumers can subscribe without forcing the application shell to own the map.
Keep composer focus local, stabilize shell callbacks, memoize shell children, and let chat/sidebar consume session activity without routing updates through AppContent.
Moves every feature out of src/components into src/modules/<feature> and
dissolves the legacy top-level frontend directories:

- src/components/* -> src/modules/*, with the banned view/ layer removed and
  subcomponents flattened to the module root or into named directories
  (tabs/, modals/, markdown/, and the git-panel view groups)
- src/contexts, src/hooks, src/stores, src/lib, src/types, src/utils and
  src/i18n are gone; their contents moved to the owning module or to
  src/shared/{context,hooks,ui,types.ts,utils.ts,constants.ts,api.ts}
- i18n is now a feature module at src/modules/i18n (config, languages,
  locales and LanguageSelector), exposing i18n and LanguageSelector
- src/shared/view/ui became src/shared/ui; llm-provider-logo became shared UI
- every application-source import uses the @/ alias; no interfaces remain
- 150 multi-file types now live in src/shared/types.ts, grouped per owner
- every feature module has an index.ts barrel and all cross-module imports
  go through it
- the frontend no longer imports package.json: the version is injected by
  Vite define and read via APP_VERSION in src/shared/constants.ts

Validation: oxlint 0 errors, tsc --noEmit clean, 57/57 client tests,
vite build succeeds.
…t module UI

Second migration milestone, applying the skill's placement rules per module:

- deletes every src/modules/*/types.ts and src/modules/*/constants.ts; each
  definition moved into its sole consumer, or (when two or more files use it)
  into src/shared/types.ts / src/shared/constants.ts
- sidebar's utils/utils.ts is split into the descriptively named
  sidebarProjectFormatting.ts and sidebarStoredPreferences.ts; auth's and
  onboarding's module-level utils.ts are inlined into their sole consumers
- prd-editor's two modals move into modals/
- every exported component now carries a comment naming its consumers
- src/shared/ui exports at the declaration, drops exports with no consumers,
  and gains LLMProviderLogo; shared UI and context components are documented

Validation: oxlint 0 errors, tsc --noEmit clean, 57/57 client tests,
vite build succeeds.
Final migration milestone:

- documents every definition in src/shared/types.ts, utils.ts and
  constants.ts, grouped with the required section separators
- relocates shared types that ended up with a single user into that file
  (AuthUser, FileIconData, PreferenceToggleItem, the TaskMaster context
  types, GitStatusFileGroup) and un-exports the ones only shared/types.ts
  itself references
- promotes FileOpenHandler/FileDiffInfo and AgentContextByProvider into
  src/shared/types.ts now that two files use each
- prunes barrel exports that no consumer imports
- main.tsx initialises i18n through the module barrel
- clears the duplicate-import, import-order and unused-import warnings the
  migration introduced (lint warnings 172 vs 173 at baseline)

Validation: npm run lint exit 0 with 0 errors, npm run typecheck clean,
57/57 client tests, npm run build:client succeeds.
…ver/

Burns down the meaningful part of the 172-warning backlog (now 122), keeping
each change small and behaviour-preserving.

Bugs

- cursor-runtime.provider: `spawnCursor` awaited `resolveResumeModel` inside an
  async Promise executor, so a rejection was swallowed and the returned promise
  never settled. Both id/model lookups now run before the promise is created.
- useChatSessionState: restore the `setIsLoadingMoreMessages` calls dropped in
  the session-store refactor (a4632dc). The state was never written, so
  ChatMessagesPane's "loading older messages" indicator was unreachable.
- SidebarHeader: `LogoBlock` was declared inside the render, remounting the
  wordmark on every render. Hoisted to a module-level component.
- CodeEditorSurface: `max-w-4xl max-w-none` conflicted; dropped the losing
  `max-w-none` so the rendered width is unchanged and the intent is explicit.
- voice.service.test: `requestedOptions?.headers` fed a non-optional member
  access, which would throw instead of failing the assertion.

Stale/unstable references

- WebSocketContext: the connect effect referenced `connect` before its
  initialization and omitted it from the deps. Moved below the callback and
  added the dep (identical reconnect transitions); `connect` is now a named
  function expression so its reconnect timer does not capture itself mid-init.
- useFileTreeOperations: same ordering issue for `handleDownload` and the two
  download helpers it dispatches to.
- PaletteOpsContext: capture the registry object once per effect instead of
  reading `ref.current` from the cleanup.
- useUiPreferences: `Math.random()` ran on every render to seed an instance id;
  replaced with `useId()`.
- useSessionStore/useChatSessionState/AskUserQuestionPanel: stable empty-array
  constants so memoized values keep their identity.
- FileTree: destructure `treeRef` out of the upload hook's result so its plain
  values are no longer treated as render-time ref reads.

Derived state and dead code

- useFileTreeSearch: derive `filteredFiles` with useMemo instead of mirroring it
  into state through an effect (one fewer render per keystroke, and the value no
  longer lags `files` by a render).
- useFileTreeViewMode: read the persisted mode in the state initializer instead
  of overwriting the default from an effect.
- useChatSessionState: drop the unused `isLoadingSessionRef`.
- project-star.service: drop two redundant `Boolean()` casts.

Left in place, deliberately: react/set-state-in-effect (74) and react/refs (16)
are mostly localStorage/prop synchronization and latest-value ref mirrors whose
fixes are structural, not local; react/only-export-components (24) is HMR-only
and inherent to the context+hook and cva-variant file layout; the four
preserve-manual-memoization findings are React Compiler bail-outs and the
compiler is not enabled; the two static-components findings are false positives
(an icon component picked from a lookup table); the remaining
no-async-promise-executor executor already wraps its body in try/catch-reject,
so removing it is a ~60-line reindent with no behavioural gain; and the one
exhaustive-deps omission in the session-loading effect is intentional and now
documented in place.

Verified with npm run lint, typecheck, test (270 pass), test:client (57 pass)
and build.
Expose ultracode for capable Claude models and remove the unused git-panel file selection controls.
P0: swap the client test runner from `tsx --test` to vitest so hooks and
components with effects can be tested at all (jsdom + renderHook + RTL).

Bug fixes:
- Renaming the selected project left the workspace header and document title
  stale. `fetchProjects` wrote only `projects`, and `selectedProject` is a
  denormalized copy of one of its rows; the rename path never resynced it.
- Opening Settings rewrote the four codeEditor* keys from a mount effect using
  a default of '14', silently changing the editor font size for anyone who had
  never set it. The keys and their defaults now come from one shared constant
  and are written only on an explicit edit.
- The provider -> tool-settings key lookup was a nested ternary ending in
  Claude's key, so dropping a provider's arm would make it inherit Claude's
  skipPermissions. It is now an explicit per-provider map.

Render fixes:
- Streaming re-parsed the whole accumulated reply through remark/rehype/KaTeX
  and Prism every 100ms. The streaming row now splits at a block boundary so
  only the block still being written is re-parsed, and Markdown is memoized.
- Syntax highlighting switched between two Prism theme objects, re-tokenizing
  every mounted code block on a theme toggle. The theme-dependent values are
  now CSS variables derived from those same objects, so a toggle is a style
  recalculation.
- The sidebar search jump rendered the entire transcript and scanned the DOM,
  falling back to the nearest rendered row when the hit was off screen — a
  silent wrong answer. It now resolves the target index from the data and
  widens the window to exactly what that target needs.
- Tool-group previews JSON.parsed tool inputs during render, on every render,
  because the memo could never hit. They are computed once while grouping.
- Two no-op cast wrappers defeated memo(ChatMessagesPane) and memo(ChatInterface).
- The editor-divider drag committed a width per mousemove and rebuilt a
  ResizeObserver per frame; it is now coalesced to one commit per frame and
  the duplicate width copy is derived instead of stored.
… jump

Adversarial review of the previous commit found real regressions. Fixed:

- StreamingMarkdown rendered its two halves in two separate `.prose`
  containers, so Tailwind Typography's first/last-child margin rules zeroed the
  gap at the seam and it popped back when the boundary moved. Both halves now
  render inside one container via a new MarkdownBody export.
- The block-boundary search split link-reference and footnote definitions away
  from their usage (rendering literal `[x]` text), treated ``` and ~~~ as
  interchangeable so a nested fence escaped its code block, ignored `$$` display
  math, and missed tab-indented code. All four produced output that differed
  from the unsplit document mid-stream.
- The search jump accepted the nearest rendered row on the first attempt, so a
  window that had not committed yet scrolled to an arbitrary message and flashed
  the highlight on it — the exact failure the rewrite was meant to remove. It
  now requires an exact match until the final attempt, where the nearest row is
  what maps a hit inside a collapsed tool group onto its group. The retry budget
  is back to ~3s, since widening the window can commit thousands of rows.
- EditorSidebar's old class string was malformed and applied nothing, so the
  pane was shrinkable; emitting a real `flex-shrink-0` changed narrow-window
  layout. Reverted to the previous behaviour.
- fetchProjects now drops superseded responses. Several triggers refresh
  concurrently and the selection is derived from the payload, so an out-of-order
  response could revert the workspace header and document title.

Removed code with no reachable path: the empty-list guard in
findSearchTargetIndex, the dark-side undefined guard in buildSyntaxTheme, the
orphaned `uuid` field on the search-target state, and the `normalizeSearchSnippet`
export that only its own test used.

Tests: the streaming split is now verified by rendering both halves and the
whole document through the real remark/rehype pipeline over every prefix of 16
fixtures — reverting any of the four boundary fixes fails it. The settings
regression is now driven through useSettingsController rather than the storage
helpers, and fails if the mount effect is reintroduced. Two tests that passed
with or without the code they named were replaced. Added RTL cleanup, the search
window arithmetic as a tested function, and a CI workflow — nothing ran the
client suite before.
UI preferences were not a store: every call site held its own useReducer and
they reconciled after the fact through a `ui-preferences:sync` CustomEvent
tagged with a per-instance id. One toggle produced four localStorage writes and
four DOM events, and useVoiceAvailable re-implemented the storage key, the JSON
parse and the listener pair — once per assistant message row.

There is now one UiPreferencesProvider in src/shared/context/, split into state
and actions contexts so writers do not re-render on a toggle. The reducer and
the legacy per-key migration move to src/shared/uiPreferences.ts, which is pure
and directly testable. The same-tab CustomEvent is gone; the `storage` listener
stays, because it is the only thing that carries a change to another tab.
The dead setPreferences/resetPreferences/dispatch surface is not carried over.

Separately, the sidebar subscribed to the whole session activity map while only
ever reading `.keys()` and `.has()`. Every provider status frame rewrites an
entry's statusText and allocates a new map, so the entire project and session
tree re-rendered several times a second during a run. SessionProtectionProvider
now also publishes a busy-id set whose identity is stable while membership is
unchanged, and the sidebar consumes that.

Also collapses the sidebar's two mutually-exclusive delete confirmations into
one pendingDeletion union — they are portalled at the same z-index and could
both be open — and drops the projectId/provider fields from the session payload,
which nothing read.

.oxlintrc.json gains src/shared/uiPreferences.ts in the frontend-shared-file
element. New files under src/shared/*.ts match boundaries/include but no element
pattern, so without this `boundaries/no-unknown` fails the lint outright.
…nnects

- `api.getMentionableFiles` built a byte-identical URL to `api.getFiles`. Two
  names for one endpoint meant the chat @-mention list and the file tree looked
  like separate resources; the mention list now calls getFiles.
- TaskMasterContext fetched the whole `/api/projects` list on boot — a second
  copy of the request the workspace already makes — and again on every
  `taskmaster-project-updated` frame, with its own reimplementation of the
  taskmaster merge. The only thing any consumer reads is `currentProject`, and
  the per-project endpoint already answers that, so refreshProjects now calls
  refreshCurrentProjectTaskMaster. The unused `projects` array goes with it, and
  `isLoading` — whose only setter lived in the removed code — with that.
- `websocket_reconnected` was handled only by chat. The project list is
  maintained purely by incremental `session_upserted` deltas, so a session
  created or renamed while the socket was down never appeared until something
  else forced a refresh. useProjectsState now re-syncs on that event.
- The two byte-identical `readJson` envelope helpers in the browser-use files
  are now one `readApiJson` in shared/api.ts. The other same-named helpers are
  deliberately different (bare casts whose callers inspect the payload, the git
  panel's abort-aware read) and are left alone rather than unified into an
  options soup that would change their behaviour.
Each removal was verified by locating every reference, not by grepping a name —
several symbols on a naive "exported but not imported" list (authenticatedFetch,
exportToMarkdown) are load-bearing intra-file.

Components: SettingsMainTabs, AgentListItem and VersionInfoSection each carried
a doc comment stating they have no consumer and each had exactly one reference —
their own export.

Chat state: `uploadingFiles` was only ever set to an empty Map, so the upload
progress overlay it fed in ComposerAttachment could never render; the state, the
prop chain and the unreachable branch are gone. Chat attachment uploads still
have no progress indicator — wiring one needs a shared authenticated-XHR path,
which is a larger change than this cleanup. `viewHiddenCount` was provably always
0 because its only non-zero setter, rewindMessages, was returned but never
destructured; it goes with clearMessages, and with the store's clearRealtime,
which had no other caller. The store also drops has/getSlot/appendRealtimeBatch/
setStatus from its public surface, and useChatSessionState drops isNearBottom
from its return object while keeping the function, which three internal callers
use for streaming auto-scroll.

shared/ui: AlertTitle, AlertDescription, CardDescription, CardAction,
CommandSeparator, ConfirmationAccepted and ConfirmationRejected appear in no JSX
anywhere and are not in the barrel, so no module could reach them. Removing
AlertDescription also made an alertVariants selector targeting its data-slot dead.

api.ts: taskmaster.{init,addTask,parsePRD,getTemplates,applyTemplate},
auth.logout and the fetch-based uploadFiles have no caller. The server routes are
untouched; these were unreachable client wrappers. GenerateTasksModal, the
natural consumer for parsePRD, makes no API call at all.

chat/tools: ToolRenderer imported its siblings back from the barrel that exports
it — a real ESM cycle. It now imports them directly, the two nested barrels are
gone, and the tools barrel is trimmed to the three symbols that cross the
directory boundary. A module has one barrel, its own index.ts.

shell/utils/auth.ts held no auth code; its only export duplicated
shared/utils.ts getSessionTitle. The two differ in null handling, so the guard
moved to the single call site rather than widening the shared helper.
…the MCP form

`selected-provider` was read in six places by four different hand-rolled
readers — only one of which validated the stored string — and written from three
modules with no same-tab notification. The git panel's reader listens only for
the cross-tab `storage` event, so after switching provider it returned the old
value for the rest of the session. src/shared/selectedProvider.ts now owns the
key, validates on read, and publishes a same-tab change event that reader
subscribes to alongside `storage`.

useMcpServers held the open form as isFormOpen + isGlobalFormOpen +
editingServer: eight representable combinations, three of them legal, including
both dialogs open at once. It is now one `serverForm` union. Both modals were
also mounted permanently, each running a full useMcpServerForm while closed;
they are now mounted only while open, which makes the `isOpen` prop and the
`if (!isOpen) return null` guard dead — both removed, along with the reset
effect's now-constant condition.

Also drops eleven hook return values with no consumer (setPermissionMode,
refreshServers, setFormData, setProviderAuthStatus, clearSaveStatus, endDrag,
starCount, setDropTarget, validateFilename, projectSortOrder and the four
useCodeEditorSettings setters), keeping the internal functions the hooks
themselves call.
The sidebar held a rename as two id/draft pairs (editingProject/editingName and
editingSession/editingSessionName). That made "a project and a session are both
mid-rename" representable, and the raw draft was passed to every project row and
every session row — so each keystroke changed props on the whole list, not just
the row being edited.

There is now one `activeRename` union. The list resolves it per row into an
`isEditing` boolean and a draft that is a constant for every other row, so a
keystroke changes props on exactly one row. With that in place
SidebarProjectItem and SidebarSessionItem are memoized, and the two remaining
inline arrows in projectListProps are hoisted into useCallback so the memo can
actually bail — the sidebar re-renders roughly every 0.5-2s during a run from
websocket session deltas.
api.ts is meant to be the endpoint map plus the request helpers the standard
requires to live beside it. It also held the entire client-side JWT lifecycle —
claim decoding, skew tolerance, refresh scheduling, storage, and the two events
AuthContext and WebSocketContext listen to — none of which is an endpoint.

Move that to src/shared/authToken.ts and repoint its five importers. Nothing is
re-exported through api.ts: the auth surface is security-sensitive and worth
naming at the import site.

Fix the layer inversion at the top of api.ts while here. readVoiceConfig and
voiceConfigHeaders are plain localStorage readers with no React in them, but
they lived in hooks/useVoiceConfig, so api.ts — the bottom of the dependency
graph — imported upwards from the hooks layer to attach request headers. They
move to src/shared/voiceConfig.ts; the hook keeps only the hook.

Both new files are registered in .oxlintrc.json's frontend-shared-file element.
boundaries/no-unknown is an error, so a new src/shared/*.ts that is not listed
fails the lint outright rather than warning.

api.test.ts becomes authToken.test.ts and gains coverage for the parts that were
previously untested: storage rejects a non-token without clobbering an existing
session, an expired read clears the session and announces it exactly once, and
the refresh delay lands at the halfway point and never goes negative. Each
assertion was mutation-checked against the code it pins.
src/shared/ui is for components two or more feature modules use. PromptInput,
Reasoning and Queue never had a second consumer: every one of their call sites
is inside src/modules/chat. They are not primitives either — they are the chat
composer form, the assistant-reasoning disclosure and the tool todo list, with
chat vocabulary in their prop names.

  shared/ui/PromptInput.tsx -> modules/chat/composer/PromptInput.tsx
  shared/ui/Reasoning.tsx   -> modules/chat/transcript/Reasoning.tsx
  shared/ui/Queue.tsx       -> modules/chat/tools/Queue.tsx

Collapsible, Card, Shimmer, Confirmation and Alert stay in shared/ui. The
two-module rule is a criterion for admission, not for eviction, and those are
genuine primitives with cross-module reach.

Three things fall out of the move, all of them dead weight the shared barrel was
keeping alive:

- usePromptInput and useReasoning were exported but nothing outside their own
  file called them. They are module-private now, which also clears the two
  react(only-export-components) warnings those files carried.
- PromptInputSubmit had a `status` prop that no call site ever passed, and fell
  back to `context?.status ?? 'ready'` when the context was missing. It is only
  ever rendered inside PromptInput, so the prop and the null-context branch were
  both unreachable — and that default would have rendered a send button for a
  composer that was actually streaming. It now reads the root's status directly.
- The "use client" directives were copied in from ai-elements. Vite is not
  Next.js; these two files were the only ones in src/ carrying them.

Consumer comments now name the component that actually renders each export
rather than "the chat module", which stopped saying anything once the files
moved into it.

promptInputSubmit.test.tsx pins the status wiring: submit when ready, a stop
control when streaming, and a hard failure outside PromptInput instead of the
silent "ready" default. Each assertion was mutation-checked.
src/modules/chat had 26 component files in one directory with no signal about
what belonged to what. The import graph is in fact almost a tree, so the grouping
is read off it rather than invented:

  composer/    everything ChatComposer renders — the menus, the attachment row,
               the queued-draft card, the permission banner, the voice button,
               the activity indicator, the token summary
  transcript/  the message list and everything it renders — MessageComponent and
               its controls, the markdown renderers, the tool group container,
               the export menu, the load-all overlay, the empty state
  modals/      CommandResultModal and the ModelLibraryPanel it opens

ChatInterface.tsx and index.ts stay at the root, which is now exactly the
module's entry component and its barrel.

Two placements differ from the obvious reading of the file names:

- PermissionRequestsBanner and ProviderSelectionEmptyState sound like top-level
  chrome, but their only consumers are ChatComposer and ChatMessagesPane
  respectively, so they sit with the component that renders them.
- ChatMessageImages is used by both MessageComponent and ComposerAttachment. It
  renders a message's images and is named for that, so it lives in transcript/
  and the composer imports across.

Only paths change. No file gains or loses an export, and no component's
behaviour is touched — the diff outside the renames is entirely import strings.
const texts: string[] = typeof content === 'string'
? [content]
: Array.isArray(content)
? content.filter((part: AnyRecord) => part?.type === 'text').map((part: AnyRecord) => String(part.text ?? ''))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/js-combine-iterations (warning)

This loops over your list twice because .filter().map() makes two passes, so do it in one pass with .reduce() or a for...of loop

Fix → Combine .map().filter() style chains into one pass with .reduce() or a for...of loop, so you only loop over the list once

Docs


if (payload.type === 'reasoning') {
const summary = Array.isArray(payload.summary)
? payload.summary.map((item: AnyRecord) => item?.text).filter(Boolean).join('\n')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/js-flatmap-filter (warning)

This loops over your list twice because .map().filter(Boolean) makes two passes, so use .flatMap() to change & drop items in one pass

Fix → Use .flatMap(item => condition ? [value] : []) to change and drop items in one pass, instead of building a throwaway array in between

Docs


if (payload.type === 'reasoning') {
const summaryText = Array.isArray(payload.summary)
? payload.summary.map((item: AnyRecord) => item?.text).filter(Boolean).join('\n')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/js-flatmap-filter (warning)

This loops over your list twice because .map().filter(Boolean) makes two passes, so use .flatMap() to change & drop items in one pass

Fix → Use .flatMap(item => condition ? [value] : []) to change and drop items in one pass, instead of building a throwaway array in between

Docs

}

for (const configFile of OPENCODE_CONFIG_FILES) {
const config = await readOpenCodeJsonFile(path.join(configDir, configFile));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/async-await-in-loop (warning)

This makes the for…of loop slow because each await runs one after another, so collect the independent calls & run them together with await Promise.all(items.map(...))

Fix → Collect the items, then use await Promise.all(items.map(...)) so independent work runs at the same time

Docs

if (entries.size <= 1 || (totalBytes <= maxTotalFileBytes && entries.size <= maxEntries)) {
break;
}
totalBytes -= entries.get(key)!.size;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-non-null-assertion-on-maybe-undefined-result (warning)

.get(...) returns undefined when the key is absent, so asserting ! here crashes on the next access when the key misses; check for the key or handle the missing value.

Fix → Drop the ! on .find/.match/.get results and handle the miss (optional chaining, a guard, or a fallback). These built-ins return undefined/null when nothing matches, so the assertion just moves the crash one line later.

Docs

// store, echoing each hydrate straight back. Comparing by content, not by
// reference, keeps the write one-directional: the reducer returns a fresh
// object for an incoming change, so an identity check would not catch it.
const lastPersistedRef = useRef<string>(JSON.stringify(preferences));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/rerender-lazy-ref-init (warning)

useRef(stringify()) rebuilds this value on every render & throws it away.

Fix → Initialize the ref lazily so expensive values are not rebuilt and discarded on every render.

Docs

@@ -71,6 +42,8 @@ export function useSessionProtection() {
const [processingSessions, setProcessingSessions] = useState<Map<string, SessionActivity>>(
new Map(),
);
const processingSessionsRef = useRef<SessionActivityMap>(processingSessions);
processingSessionsRef.current = processingSessions;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-ref-current-in-render (error)

This ref is mutated during render. React can replay or discard render work, so the mutation can leak from UI that never commits.

Fix → Move ref writes into an event handler or effect. Render must stay pure because React can replay or discard it. The predictable null-guarded lazy initialization pattern remains supported.

Docs

Comment thread src/shared/ui/Badge.tsx

const badgeVariants = cva(
export const badgeVariants = cva(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/only-export-components (warning)

This file exports non-components, so Fast Refresh can't safely preserve component state.

Fix → Move non-component exports out of component files so Fast Refresh can preserve component state instead of full-reloading.

Docs

Comment thread src/shared/ui/Button.tsx

// Keep visual variants centralized so all button usages stay consistent.
const buttonVariants = cva(
export const buttonVariants = cva(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/only-export-components (warning)

This file exports non-components, so Fast Refresh can't safely preserve component state.

Fix → Move non-component exports out of component files so Fast Refresh can preserve component state instead of full-reloading.

Docs


function writeMirror(): void {
try {
localStorage.setItem(MIRROR_STORAGE_KEY, JSON.stringify(preferences));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/client-localstorage-no-version (warning)

localStorage.setItem("user-preferences", JSON.stringify(...)) has no version, so changing the data shape later crashes your users' saved sessions. Add one to the key (e.g. "user-preferences:v1").

Fix → Put a version in the storage key (e.g. "myKey:v1"). If you change the data shape later, old saved data can be ignored instead of crashing the app.

Docs


return (
<I18nextProvider i18n={i18n}>
<TranscriptRenderContext.Provider value={{ isExporting: true }}>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/jsx-no-constructed-context-values (warning)

Every reader of this context redraws on each render because you build its value inline.

Fix → Wrap the context value in useMemo or move it outside the component so consumers do not redraw every render.

Docs

}
searchScrollActiveRef.current = false;
setSearchTarget(null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-adjust-state-on-prop-change (warning)

This effect adjusts state after a prop changes, so users briefly see the stale value.

Fix → Remove the adjustment effect by deriving values during render, resetting the component with a key, or updating related state in the event that changes the prop. Avoid tracking the previous prop in more state, which preserves the duplication. See https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes

Docs

const phrase = normalizeSearchSnippet(target.snippet);
if (phrase.length >= MIN_SNIPPET_LENGTH) {
const matchIndex = messages.findIndex((message) =>
getSearchableText(message).includes(phrase),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/js-set-map-lookups (warning)

This scales poorly because array.includes() inside a loop scans the whole list every time. Use a Set for constant-time lookups.

Fix → Use a Set or Map when you check for the same items over and over. Array.includes/find scans the whole list each time

Docs

// Keep search results visible by opening every matching ancestor directory once per query update.
expandDirectories(collectExpandedDirectoryPaths(filtered));
}, [files, searchQuery, expandDirectories]);
expandDirectories(collectExpandedDirectoryPaths(filteredFiles));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-pass-data-to-parent (warning)

Handing data back to a parent from a useEffect costs your users an extra render.

Fix → Fetch the data in the parent and pass it down as a prop (or return it from the hook), instead of handing it back up through a prop callback in a useEffect. See https://react.dev/learn/you-might-not-need-an-effect#passing-data-to-the-parent

Docs

// Keep search results visible by opening every matching ancestor directory once per query update.
expandDirectories(collectExpandedDirectoryPaths(filtered));
}, [files, searchQuery, expandDirectories]);
expandDirectories(collectExpandedDirectoryPaths(filteredFiles));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-pass-live-state-to-parent (warning)

Pushing state up to a parent from a useEffect costs your users an extra render.

Fix → Move the state up to the parent (or return it from the hook), instead of handing it back up through a prop callback in a useEffect. See https://react.dev/learn/you-might-not-need-an-effect#notifying-parent-components-about-state-changes

Docs

Comment thread src/shared/chatDrafts.ts

function writeMirror(): void {
try {
localStorage.setItem(MIRROR_STORAGE_KEY, JSON.stringify(Object.fromEntries(drafts)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/client-localstorage-no-version (warning)

localStorage.setItem("chat-drafts", JSON.stringify(...)) has no version, so changing the data shape later crashes your users' saved sessions. Add one to the key (e.g. "chat-drafts:v1").

Fix → Put a version in the storage key (e.g. "myKey:v1"). If you change the data shape later, old saved data can be ignored instead of crashing the app.

Docs

// store, echoing each hydrate straight back. Comparing by content, not by
// reference, keeps the write one-directional: the reducer returns a fresh
// object for an incoming change, so an identity check would not catch it.
const lastPersistedRef = useRef<string>(JSON.stringify(preferences));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/rerender-lazy-ref-init (warning)

useRef(stringify()) rebuilds this value on every render & throws it away.

Fix → Initialize the ref lazily so expensive values are not rebuilt and discarded on every render.

Docs

Comment thread src/shared/ui/Badge.tsx

const badgeVariants = cva(
export const badgeVariants = cva(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/only-export-components (warning)

This file exports non-components, so Fast Refresh can't safely preserve component state.

Fix → Move non-component exports out of component files so Fast Refresh can preserve component state instead of full-reloading.

Docs

Comment thread src/shared/ui/Button.tsx

// Keep visual variants centralized so all button usages stay consistent.
const buttonVariants = cva(
export const buttonVariants = cva(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/only-export-components (warning)

This file exports non-components, so Fast Refresh can't safely preserve component state.

Fix → Move non-component exports out of component files so Fast Refresh can preserve component state instead of full-reloading.

Docs


function writeMirror(): void {
try {
localStorage.setItem(MIRROR_STORAGE_KEY, JSON.stringify(preferences));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/client-localstorage-no-version (warning)

localStorage.setItem("user-preferences", JSON.stringify(...)) has no version, so changing the data shape later crashes your users' saved sessions. Add one to the key (e.g. "user-preferences:v1").

Fix → Put a version in the storage key (e.g. "myKey:v1"). If you change the data shape later, old saved data can be ignored instead of crashing the app.

Docs

Comment thread src/modules/file-tree/FileTree.tsx Fixed
Comment thread src/modules/file-tree/FileTree.tsx Fixed
Comment thread src/modules/file-tree/FileTree.tsx Fixed
Comment thread src/modules/file-tree/FileTree.tsx Fixed
Comment thread src/modules/file-tree/FileTree.tsx Fixed
Comment thread src/modules/file-tree/FileTree.tsx Fixed
@blackmammoth
blackmammoth force-pushed the perf/chat-and-project-loading branch from 7b078bd to d3cb06a Compare August 26, 2026 12:09
if (entries.size <= 1 || (totalBytes <= maxTotalFileBytes && entries.size <= maxEntries)) {
break;
}
totalBytes -= entries.get(key)!.size;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-non-null-assertion-on-maybe-undefined-result (warning)

.get(...) returns undefined when the key is absent, so asserting ! here crashes on the next access when the key misses; check for the key or handle the missing value.

Fix → Drop the ! on .find/.match/.get results and handle the miss (optional chaining, a guard, or a fallback). These built-ins return undefined/null when nothing matches, so the assertion just moves the crash one line later.

Docs

// Sequentially: a session can only have one run at a time, and two due
// messages for the same session must not race each other into it.
for (const row of due) {
await sendClaimedMessage(row, runtime);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/async-await-in-loop (warning)

This makes the for…of loop slow because each await runs one after another, so collect the independent calls & run them together with await Promise.all(items.map(...))

Fix → Collect the items, then use await Promise.all(items.map(...)) so independent work runs at the same time

Docs

@@ -90,7 +92,7 @@ export default function LoginForm() {
<button

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-transition-all (warning)

Your users see janky animation because transition-all animates every property that changes, including expensive layout ones and instant ones like focus rings. Name the properties: transition-colors, transition-opacity, or transition-transform.

Fix → List the specific properties: transition: "opacity 200ms, transform 200ms". In Tailwind, use transition-colors, transition-opacity, or transition-transform

Docs

@@ -136,7 +138,7 @@ export default function SetupForm() {
<button

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-transition-all (warning)

Your users see janky animation because transition-all animates every property that changes, including expensive layout ones and instant ones like focus rings. Name the properties: transition-colors, transition-opacity, or transition-transform.

Fix → List the specific properties: transition: "opacity 200ms, transform 200ms". In Tailwind, use transition-colors, transition-opacity, or transition-transform

Docs

const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}/stop`, { method: 'POST' });
await readJson(response);
const response = await api.browserUse.stopSession(selectedSession.id);
await readApiJson(response);
});

const deleteSession = () => runAction(async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/no-impure-state-updater (error)

This state updater performs the nested state update "setIsFullscreen()". React may run updater functions more than once, so side effects here can repeat or observe inconsistent external state.

Fix → Keep state updater callbacks pure and return only the next state. Move notifications, storage, timers, ref writes, and other external work into the event or effect that queues the update.

Docs

Comment thread src/shared/chatDrafts.ts

function writeMirror(): void {
try {
localStorage.setItem(MIRROR_STORAGE_KEY, JSON.stringify(Object.fromEntries(drafts)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/client-localstorage-no-version (warning)

localStorage.setItem("chat-drafts", JSON.stringify(...)) has no version, so changing the data shape later crashes your users' saved sessions. Add one to the key (e.g. "chat-drafts:v1").

Fix → Put a version in the storage key (e.g. "myKey:v1"). If you change the data shape later, old saved data can be ignored instead of crashing the app.

Docs

// store, echoing each hydrate straight back. Comparing by content, not by
// reference, keeps the write one-directional: the reducer returns a fresh
// object for an incoming change, so an identity check would not catch it.
const lastPersistedRef = useRef<string>(JSON.stringify(preferences));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/rerender-lazy-ref-init (warning)

useRef(stringify()) rebuilds this value on every render & throws it away.

Fix → Initialize the ref lazily so expensive values are not rebuilt and discarded on every render.

Docs

Comment thread src/shared/ui/Badge.tsx

const badgeVariants = cva(
export const badgeVariants = cva(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/only-export-components (warning)

This file exports non-components, so Fast Refresh can't safely preserve component state.

Fix → Move non-component exports out of component files so Fast Refresh can preserve component state instead of full-reloading.

Docs

Comment thread src/shared/ui/Button.tsx

// Keep visual variants centralized so all button usages stay consistent.
const buttonVariants = cva(
export const buttonVariants = cva(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/only-export-components (warning)

This file exports non-components, so Fast Refresh can't safely preserve component state.

Fix → Move non-component exports out of component files so Fast Refresh can preserve component state instead of full-reloading.

Docs


function writeMirror(): void {
try {
localStorage.setItem(MIRROR_STORAGE_KEY, JSON.stringify(preferences));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

React Doctor · react-doctor/client-localstorage-no-version (warning)

localStorage.setItem("user-preferences", JSON.stringify(...)) has no version, so changing the data shape later crashes your users' saved sessions. Add one to the key (e.g. "user-preferences:v1").

Fix → Put a version in the storage key (e.g. "myKey:v1"). If you change the data shape later, old saved data can be ignored instead of crashing the app.

Docs

Drops the CI workflow, the react-doctor agent skill, and the doctor
script/devDependency.
The composer's token counter is driven by token_budget status events
emitted per SDK stream message. Two message classes corrupted it during
generation:

- System task_progress/task_notification events carry a top-level usage
  shaped {total_tokens, tool_uses, duration_ms}; reading Anthropic keys
  off it produced an all-zero budget that flashed "0" until the next
  assistant message restored the real number.
- Subagent messages (parent_tool_use_id set) carry the subagent's own,
  smaller context usage, making the counter drop and bounce back.

extractTokenBudget now only reads assistant/result messages from the
main thread. The client additionally ignores token_budget events not
stamped with the viewed session, so a second running session can no
longer overwrite the visible counter.
Codex reports API failures twice: the stream emits an error item or
turn.failed, then the SDK throws "Codex Exec exited with code N: <stderr>"
when the process dies. The catch block sent that raw stderr dump as a
second error message, so the rendered error card was followed by unrelated
CLI log lines. Skip the thrown wrapper when the stream already surfaced the
failure; startup failures still report as before.
The Agent SDK emits a subagent's own prompt as a user message carrying
parent_tool_use_id. The runtime forwarded it like any other message, so it
rendered as a top-level user bubble right below the Agent tool card that
already shows that prompt — and it vanished on reload, because Claude keeps
that turn in the subagent's sidechain rather than the session transcript.

Skip those echoes in the stream loop. Subagent tool traffic and the real
session prompt are unaffected.
…ith hard reload

Give the environment enough time to actually restart before reloading, and
bypass the cache on reload so stale assets aren't served post-update.
The Claude CLI only offers bypass-permissions in its shift+tab mode
cycle when launched with --dangerously-skip-permissions, so shell-tab
sessions could never enable it. Add a Bypass toggle to the shell header
(Claude provider only, seeded from the chat composer's skip-permissions
setting) that relaunches the CLI with the flag, for both fresh and
resumed sessions.
…indows

The Agent SDK launches pathToClaudeCodeExecutable with a raw
child_process.spawn, which never consults PATH or PATHEXT. The resolver's
fallback was the bare string `claude`, so whenever resolution failed the SDK
reported "Claude Code native binary not found at claude" on a machine where the
CLI is installed and on PATH.

That fallback is reachable whenever the only Claude Code visible on PATH is an
older npm install shipping cli.js instead of bin/claude.exe. Return undefined
instead and leave the option unset, so the SDK falls back to the native binary
it ships; an explicitly configured CLAUDE_CLI_PATH is still honoured verbatim,
and the auth probe keeps the bare command since cross-spawn resolves shims.

Claude-Session: https://claude.ai/code/session_01MeP3mSuvuPQ1wxHC3r1rnz
The composer's counter shows context-window occupancy, but the runtime also
fed it the turn-ending `result` message. `result.usage` is the turn's bill —
every API request it made, summed — so a multi-request turn reported several
times the context the conversation actually holds, and the next assistant
message dropped it back. A turn that spawns a subagent absorbs that agent's
requests into the same sum, which is why the bouncing only showed up once
subagents were running.

Assistant messages are now the only source for the counter; the cumulative
reading survives as `extractCumulativeTokenBudget`, used only when a run
produced no per-assistant usage at all.

The transcript-derived writer of the same counter had two matching defects:
`<synthetic>` rows (interrupts, API errors, "No response requested.") carry an
all-zero usage block rather than none, so one landing last zeroed the counter
on the next history refresh; and sidechain rows reported a subagent's context.
Both are now skipped.
…live

A running subagent's rows stream in stamped with the spawning Task's
tool id (parentToolUseId), but the client projection ignored the stamp
and rendered them as the session's own top-level tool calls. They only
moved inside the subagent panel after a refresh, when the server ships
the sidecar-indexed timeline as subagentTools on the Task row.

normalizedToChatMessages now folds parented rows into that row's
SubagentActivity timeline as they arrive — tool calls with results
attached, assistant prose and thinking, the echoed task prompt excluded
to match the server's reader. The projection cache tracks the newest
folded row per container so the panel keeps growing, and when a mid-run
history refresh attaches a partial server timeline the longer of the
two wins.
…fresh

Answering a permission prompt resolves it over the inbound socket only
(chat.permission-response -> resolveToolApproval), while the original
permission_request frame sits in the run's replay buffer. A mid-run page
refresh subscribes from lastSeq 0 and replays the whole buffer, so the
request re-arrived with nothing to retract it and the already-answered
prompt resurrected — and a second tab watching the same run kept it
forever, since only cancellations ever emitted an outbound frame.

The Claude runtime now emits permission_resolved on the run stream when
a client answers, so it lands in the replay buffer and on every attached
socket. The client removes the prompt's requestId on that frame, keeps
it out of the message store like the other permission kinds, and skips
the unread-attention mark for resolutions in background sessions.
Two concurrently authored variations document the same six subsystems —
websocket transport, conversation handoff, the realtime stream,
scrolling, lazy loading, and tool views. They are kept side by side so
the better one can be chosen or the two merged; the top-level README
compares them and records which set was fact-checked against source.
The single-file realtime map and the virtualized-lists assessment they
supersede are removed.

Both sets already describe the permission_resolved frame from the
previous commit, with the shifted line citations corrected.
The variation-2 set was not fact-checked against the source, so the
side-by-side comparison ends here: variation-1 — whose claims were
re-verified in an adversarial pass — is promoted to docs/architecture/
as the single chat-runtime documentation, and the comparison README is
replaced by the set's own index. Internal cross-links are relative and
survive the move unchanged.
@blackmammoth
blackmammoth force-pushed the perf/chat-and-project-loading branch from 6325322 to 7f09933 Compare August 27, 2026 07:19
@blackmammoth
blackmammoth marked this pull request as ready for review August 27, 2026 07:41
@blackmammoth
blackmammoth requested a review from viper151 August 27, 2026 07:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment