Skip to content

refactor(threads): rust core single source of truth, drop redux thread slice#1199

Closed
senamakel wants to merge 1 commit into
tinyhumansai:mainfrom
senamakel:refactor/threads-rust-source-of-truth
Closed

refactor(threads): rust core single source of truth, drop redux thread slice#1199
senamakel wants to merge 1 commit into
tinyhumansai:mainfrom
senamakel:refactor/threads-rust-source-of-truth

Conversation

@senamakel
Copy link
Copy Markdown
Member

@senamakel senamakel commented May 5, 2026

Summary

  • Rust core (the user-visible fix): web::build_session_agent was setting the per-thread agent name AFTER .build(), but session_key is frozen during the build. Transcripts were written as {ts}_orchestrator.jsonl ignoring the thread, and resume scans (looking for the post-rename, thread-scoped name) never matched — so every cache-miss continuation started with empty history.
  • New Agent::from_config_for_agent_with_name(config, agent_id, definition_name) plumbs the name into the builder before .build(). web.rs uses it; the post-build setter call is gone.
  • Frontend: threads / messages / reactions pulled fresh from core on every selection and re-pulled after each chat_done / chat_error / proactive append (via a small messagesRefreshBus). selectedThreadId moves to a URL hash query param.
  • threadSlice.ts (370 lines) + its persist config deleted. chatRuntimeSlice (livestream preview, tool timeline) untouched.

Problem

Users reported that continuing a thread "forgot" prior context — the agent appeared to start a fresh session even though the UI showed the right history. Two compounding issues:

  1. Rust: transcript files weren't actually scoped per thread (a comment claimed they were, but the implementation set the per-thread name after session_key was frozen). So on cache miss the resume lookup couldn't find a matching file → cached_transcript_messages = None → empty history at turn start.
  2. UI: Redux thread cache could drift from the on-disk JSONL — even when core was correct, the renderer might show stale messages.

Solution

Rust: new public constructor that accepts a separate definition_name and passes it to AgentBuilder before .build() so session_key correctly captures the per-thread suffix. web::build_session_agent calls it. Regression test (transcript_resume_is_scoped_by_definition_name) locks the contract: two definition_names in the same workspace must resume independently, and matching names must resume.

Frontend: new app/src/lib/threads/:

  • ThreadsContext — fetch threads on mount, expose refresh/create/remove/updateLabels/purge/generateTitleIfNeeded.
  • useThreadMessages(threadId) — auto-fetches on threadId change AND on messagesRefreshBus.notify(threadId). No cross-switch cache; truth = core.
  • messagesRefreshBus — tiny module-level pub/sub so ChatRuntimeProvider can invalidate the mounted messages hook from outside the React tree.
  • useSelectedThread — URL hash ?t=<id> (replaces redux-persist).
  • useActiveThread — in-memory turn target.

ChatRuntimeProvider.onDone / onError (after error-row append) / onProactiveMessage (after proactive-row append) all call notifyThreadMessagesRefresh(threadId).

Submission Checklist

  • Tests added — Rust transcript_resume_is_scoped_by_definition_name; new TS unit tests for ThreadsContext, useThreadMessages (incl. bus refetch), useSelectedThread, messagesRefreshBus; updated ChatRuntimeProvider / CoreStateProvider / Conversations / OnboardingLayout tests.
  • Diff coverage ≥ 80% — to be confirmed by CI.
  • Coverage matrix updated — N/A (behaviour-only fix; existing thread feature rows still apply).
  • No new external network deps.
  • Manual smoke — N/A.
  • Linked issue closed — N/A.

Impact

  • Desktop only. Both fixes ship together so the reported bug is actually resolved — the UI fix alone wouldn't have helped because core itself was returning empty history.
  • One-time UX blip on first launch: redux-persisted selectedThreadId is dropped; users land on the most-recent thread once, then the URL takes over.
  • No schema changes. Existing transcript files written by the buggy path ({ts}_orchestrator.jsonl style) won't be picked up by the new resume scan — they're effectively orphaned. Acceptable given the alternative (continuing to load some other thread's transcript) is worse.
  • One extra getThreadMessages RPC per turn end (and per proactive append). Bounded by visible thread.

Related

  • Closes:
  • Follow-up: revisit whether ChatRuntimeProvider's direct threadApi.appendMessage calls (proactive + error message) should move into core so the UI never writes JSONL.

Test plan

  • Send a message, get a reply, restart the app, send another message in the same thread — assistant references prior context.
  • Two threads. Have a turn complete in thread A while viewing thread B. Switch to thread A — assistant's reply is there without manual refresh.
  • Reload while on a thread — URL ?t= restores the same thread.
  • Cancel a turn (start a new message before the prior finishes) → next continuation still has full history.
  • Cold-boot identity hydration (fix(threads): preserve selectedThreadId on cold-boot identity hydration #1196) flow still preserves selection.

Summary by CodeRabbit

  • Refactor

    • Thread and message state moved to a new context layer for more consistent behavior.
    • Thread selection now persists in the URL and is preserved across identity changes.
    • Active-thread tracking improved so send/idle indicators and proactive messages behave more reliably.
    • Message refresh notifications now update only the relevant thread.
  • Tests

    • Added extensive tests covering threads, message fetching, selection, refresh bus, and related hooks.

@senamakel senamakel requested a review from a team May 5, 2026 01:34
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 5, 2026

📝 Walkthrough

Walkthrough

This PR removes the Redux thread slice and migrates thread/message management to React context and hooks (ThreadsProvider, ActiveThreadProvider, useThreads, useThreadMessages, useSelectedThread, useActiveThread). Consumers and providers are updated and new tests/bus utilities are added. Rust agent session naming was scoped by definition_name.

Changes

Frontend: Thread State Migration (single DAG)

Layer / File(s) Summary
API / Data shape
app/src/lib/threads/*, app/src/lib/threads/messagesRefreshBus.ts
Introduces thread/context hooks and in-memory per-thread pub/sub: ThreadsProvider, useThreads, useThreadMessages, useSelectedThread, useActiveThread, and messagesRefreshBus.
Core Implementation
app/src/lib/threads/ThreadsContext.tsx, app/src/lib/threads/useThreadMessages.ts, app/src/lib/threads/useSelectedThread.ts, app/src/lib/threads/useActiveThread.tsx
Implements providers, fetch/refresh coalescing, optimistic updates, reaction persistence, URL-backed selected-thread logic, and active-thread state.
Integration / Wiring
app/src/App.tsx, app/src/providers/CoreStateProvider.tsx, app/src/providers/ChatRuntimeProvider.tsx, app/src/pages/Conversations.tsx, app/src/pages/conversations/components/WorkerThreadRefCard.tsx
Wires new providers into app tree, replaces Redux thread reads/dispatches with hooks, emits openhuman:threads-refresh instead of dispatching thread reloads, and changes ChatRuntimeProvider to append to core JSONL + notify refresh bus.
Store removal
app/src/store/index.ts, app/src/store/threadSlice.ts
Removes thread reducer, persistence config, and deletes the entire threadSlice module (thunks/actions/reducers).
Tests: new & updated
app/src/lib/threads/__tests__/*, app/src/lib/threads/messagesRefreshBus.test.ts, app/src/lib/threads/useThreadMessages.test.ts, app/src/pages/__tests__/Conversations.render.test.tsx, app/src/providers/__tests__/*, app/src/pages/onboarding/__tests__/OnboardingLayout.test.tsx
Adds tests for new hooks and bus; updates many consumer tests to wrap with new providers and to mock threadApi instead of asserting Redux thread-slice behavior; removes old threadSlice test suite.

Backend: Agent/session transcript scoping (independent DAG)

Layer / File(s) Summary
API surface / entry
src/openhuman/agent/harness/session/builder.rs
Adds from_config_for_agent_with_name(..., definition_name) and forwards existing from_config_for_agent to it.
Builder internals
src/openhuman/agent/harness/session/builder.rs
Propagates definition_name into session construction and stamps agent_definition_name on built Agent (replacing prior agent_id-based stamping).
Call sites
src/openhuman/channels/providers/web.rs
Precomputes per-thread definition_name (truncating thread id when needed) and calls the new builder helper to scope transcripts by definition name.
Tests
src/openhuman/agent/harness/session/turn_tests.rs
Adds a test ensuring transcript resume is isolated by agent_definition_name (scoped session resume).

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related issues

Possibly related PRs

Poem

🐰
I hopped from store to context nest,
Threads now tend themselves the best,
Messages bloom, refresh bus sings,
Agents learn new scoped name things,
A little hop for clearer rest.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'refactor(threads): rust core single source of truth, drop redux thread slice' accurately summarizes the main architectural change—moving thread state from Redux to the Rust core and removing the Redux thread slice.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/providers/__tests__/ChatRuntimeProvider.test.tsx (1)

80-90: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reset window.location.hash in the shared test setup.

Multiple cases below mutate the global hash, and most of them leave it behind. Because proactive resolution reads window.location.hash directly, that makes later tests depend on execution order.

🧪 Minimal stabilization
-import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
...
   beforeEach(() => {
     vi.clearAllMocks();
     mockNotifyThreadMessagesRefresh.mockClear();
     resetRuntimeState();
+    window.location.hash = '#/conversations';
     vi.mocked(threadApi.appendMessage).mockImplementation(async (_tid, msg) => msg);
     vi.mocked(threadApi.getThreads).mockResolvedValue({ threads: [], count: 0 });
     vi.mocked(threadApi.generateTitleIfNeeded).mockResolvedValue({
       id: 'tid',
       title: 'new',
     } as never);
   });
+
+  afterEach(() => {
+    window.location.hash = '#/conversations';
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/providers/__tests__/ChatRuntimeProvider.test.tsx` around lines 80 -
90, The test setup in beforeEach of ChatRuntimeProvider.test.tsx doesn't reset
window.location.hash, causing test order dependencies; update the beforeEach
(the block that calls vi.clearAllMocks(), resetRuntimeState(), and mocks
threadApi methods) to explicitly reset window.location.hash (e.g., set to empty
string or default value) so each test starts with a clean hash state and
proactive resolution won't be affected by previous tests.
🧹 Nitpick comments (2)
app/src/pages/onboarding/__tests__/OnboardingLayout.test.tsx (1)

175-184: ⚡ Quick win

Drop this duplicate “no welcome thread” test case.

This assertion is already covered by the test at Line 141-150, so keeping both adds churn without increasing behavior coverage.

Proposed cleanup
-  // [`#1123`] Old test — welcome thread in Redux state — replaced:
-  // The welcome thread is no longer stored in Redux (threadSlice removed).
-  it('does NOT call threadApi.createNewThread on completeAndExit (Joyride flow has no welcome thread)', async () => {
-    await setupLayout();
-
-    await act(async () => {
-      fireEvent.click(screen.getByTestId('complete-btn'));
-    });
-
-    // threadApi.createNewThread is not called — welcome agent flow is gone.
-    expect(mockCreateNewThreadArg).not.toHaveBeenCalled();
-  });

As per coding guidelines, “Prefer testing behavior over implementation details; use existing helpers … before adding new harness code.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/pages/onboarding/__tests__/OnboardingLayout.test.tsx` around lines
175 - 184, Remove the duplicate test "does NOT call threadApi.createNewThread on
completeAndExit (Joyride flow has no welcome thread)" — delete the entire
it(...) block that calls setupLayout(), fires complete-btn, and asserts
mockCreateNewThreadArg not.toHaveBeenCalled(); the behavior is already covered
by the earlier test (which uses setupLayout and the same
mockCreateNewThreadArg), so remove this redundant test to avoid churn.
app/src/lib/threads/__tests__/useThreadMessages.test.tsx (1)

191-207: ⚡ Quick win

Add a regression test for “deselect while fetch is in-flight”.

The current null-state test covers immediate clearing, but not the race where the prior fetch resolves afterward. A deferred getThreadMessages promise plus rerender({ tid: null }) would lock in protection against stale repopulation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/lib/threads/__tests__/useThreadMessages.test.tsx` around lines 191 -
207, The test 'clears state when threadId becomes null' needs to also cover the
race where a pending fetch resolves after deselecting: change the mock for
threadApi.getThreadMessages to return a deferred promise (store resolve/reject
and resolve it after rerender({ tid: null })), call rerender to set tid to null
while the promise is still pending, then resolve the promise and assert that
useThreadMessages still has messages.length === 0, isLoading === false, and
error === null; update references in the test to the useThreadMessages hook and
the mocked threadApi.getThreadMessages to ensure the deferred promise behavior
is used for this regression check.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/src/lib/threads/ThreadsContext.tsx`:
- Around line 42-45: The doFetch function currently catches and swallows errors
so callers like create/remove/updateLabels/generateTitleIfNeeded that await
refresh() cannot detect failures; change doFetch to log the error but rethrow it
(or remove the try/catch) so refresh() rejects on failure, and ensure the
mounted-check logic around state updates (used in refresh) is explicit so we
only set state when still mounted; update references to doFetch and refresh in
ThreadsContext.tsx so callers receive the propagated error and UI reconciliation
can fail fast.
- Around line 52-60: The current refresh() coalescing returns the in-flight
promise (refreshInFlightRef) and skips any trailing fetch, so mid-flight
mutations can be missed; change refresh() to, when refreshInFlightRef.current
exists, set a pendingRefreshRef (e.g., pendingRefreshRef.current = true) and
attach logic to the in-flight promise's finally (or then) to, after it clears
refreshInFlightRef, check pendingRefreshRef and run doFetch() once more
(resetting pendingRefreshRef) so one trailing refresh is queued; keep
setIsLoading semantics and ensure refresh() still returns the original in-flight
promise while scheduling the trailing fetch.

In `@app/src/lib/threads/useThreadMessages.ts`:
- Around line 59-66: When threadId becomes falsy in the useEffect block,
increment the fetch sequence token used to validate in-flight responses (the
same sequence checked when resolving async fetches) so any pending responses are
treated as stale; in the branch that calls setMessages([]), setError(null), and
setIsLoading(false) also bump the sequence (e.g., increment fetchSequence or
fetchSequenceRef.current) to ensure old fetch handlers do not repopulate
messages.

In `@app/src/pages/Conversations.tsx`:
- Around line 234-254: The one-shot guard using threadListSettledRef inside the
useEffect can leave selectedThreadId stale when threads later become empty or
change; update the logic in the effect that watches threads (and any related
startup effect) to always revalidate selectedThreadId on every threads change:
if threads.length > 0 ensure selectedThreadId exists in threads and call
setSelectedThreadId(threads[0].id) when missing, and if threads.length === 0
either programmatically create a new thread (invoke the existing
create/thread-creation function) or set a clear "no-thread" state that disables
the composer; also audit handleSendMessage to proactively create a thread when
selectedThreadId is null before returning (reference: threadListSettledRef,
selectedThreadId, setSelectedThreadId, useEffect watching threads, and
handleSendMessage).

In `@app/src/providers/ChatRuntimeProvider.tsx`:
- Around line 599-615: After successfully appending the proactiveMsg via
threadApi.appendMessage (use the same success path that calls rtLog and
notifyThreadMessagesRefresh), trigger the thread-list refresh so the sidebar
metadata (lastMessageAt, messageCount, ordering) updates; specifically, after
await threadApi.appendMessage(targetThreadId, proactiveMsg) add a call to the
existing thread-list refresh dispatcher (the same event/function used elsewhere
to refresh the thread list) so the sidebar re-fetches; apply the same change to
the other identical block noted around lines 713-731 to ensure both proactive
and error-path JSONL appends refresh the thread list.

---

Outside diff comments:
In `@app/src/providers/__tests__/ChatRuntimeProvider.test.tsx`:
- Around line 80-90: The test setup in beforeEach of
ChatRuntimeProvider.test.tsx doesn't reset window.location.hash, causing test
order dependencies; update the beforeEach (the block that calls
vi.clearAllMocks(), resetRuntimeState(), and mocks threadApi methods) to
explicitly reset window.location.hash (e.g., set to empty string or default
value) so each test starts with a clean hash state and proactive resolution
won't be affected by previous tests.

---

Nitpick comments:
In `@app/src/lib/threads/__tests__/useThreadMessages.test.tsx`:
- Around line 191-207: The test 'clears state when threadId becomes null' needs
to also cover the race where a pending fetch resolves after deselecting: change
the mock for threadApi.getThreadMessages to return a deferred promise (store
resolve/reject and resolve it after rerender({ tid: null })), call rerender to
set tid to null while the promise is still pending, then resolve the promise and
assert that useThreadMessages still has messages.length === 0, isLoading ===
false, and error === null; update references in the test to the
useThreadMessages hook and the mocked threadApi.getThreadMessages to ensure the
deferred promise behavior is used for this regression check.

In `@app/src/pages/onboarding/__tests__/OnboardingLayout.test.tsx`:
- Around line 175-184: Remove the duplicate test "does NOT call
threadApi.createNewThread on completeAndExit (Joyride flow has no welcome
thread)" — delete the entire it(...) block that calls setupLayout(), fires
complete-btn, and asserts mockCreateNewThreadArg not.toHaveBeenCalled(); the
behavior is already covered by the earlier test (which uses setupLayout and the
same mockCreateNewThreadArg), so remove this redundant test to avoid churn.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3a2d15a1-8eb2-497d-baee-930f0ea04259

📥 Commits

Reviewing files that changed from the base of the PR and between 24019ed and 3be500a.

📒 Files selected for processing (21)
  • app/src/App.tsx
  • app/src/lib/threads/ThreadsContext.tsx
  • app/src/lib/threads/__tests__/ThreadsContext.test.tsx
  • app/src/lib/threads/__tests__/messagesRefreshBus.test.ts
  • app/src/lib/threads/__tests__/useSelectedThread.test.tsx
  • app/src/lib/threads/__tests__/useThreadMessages.test.tsx
  • app/src/lib/threads/messagesRefreshBus.ts
  • app/src/lib/threads/useActiveThread.tsx
  • app/src/lib/threads/useSelectedThread.ts
  • app/src/lib/threads/useThreadMessages.ts
  • app/src/pages/Conversations.tsx
  • app/src/pages/__tests__/Conversations.render.test.tsx
  • app/src/pages/conversations/components/WorkerThreadRefCard.tsx
  • app/src/pages/onboarding/__tests__/OnboardingLayout.test.tsx
  • app/src/providers/ChatRuntimeProvider.tsx
  • app/src/providers/CoreStateProvider.tsx
  • app/src/providers/__tests__/ChatRuntimeProvider.test.tsx
  • app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx
  • app/src/store/__tests__/threadSlice.test.ts
  • app/src/store/index.ts
  • app/src/store/threadSlice.ts
💤 Files with no reviewable changes (3)
  • app/src/store/threadSlice.ts
  • app/src/store/tests/threadSlice.test.ts
  • app/src/store/index.ts

Comment on lines +42 to +45
} catch (err) {
log('[threads-ctx] fetch error %O', err);
console.warn('[threads-ctx] getThreads failed:', err);
} finally {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

doFetch swallows errors, so callers can’t detect failed reconciliation.

create/remove/updateLabels/generateTitleIfNeeded await refresh(), but doFetch catches and resolves. That can leave UI stale while mutation flows still appear successful.

💡 Proposed fix (propagate refresh failure; handle mount explicitly)
   const doFetch = useCallback(async () => {
     log('[threads-ctx] fetch start');
     try {
       const data = await threadApi.getThreads();
       log('[threads-ctx] fetch done count=%d', data.threads.length);
       setThreads(data.threads);
     } catch (err) {
       log('[threads-ctx] fetch error %O', err);
       console.warn('[threads-ctx] getThreads failed:', err);
+      throw err;
     } finally {
       setIsLoading(false);
     }
   }, []);

   useEffect(() => {
     log('[threads-ctx] mount — initial fetch');
-    void refresh();
+    void refresh().catch(() => {
+      // already logged in doFetch
+    });
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);

Also applies to: 64-67

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/lib/threads/ThreadsContext.tsx` around lines 42 - 45, The doFetch
function currently catches and swallows errors so callers like
create/remove/updateLabels/generateTitleIfNeeded that await refresh() cannot
detect failures; change doFetch to log the error but rethrow it (or remove the
try/catch) so refresh() rejects on failure, and ensure the mounted-check logic
around state updates (used in refresh) is explicit so we only set state when
still mounted; update references to doFetch and refresh in ThreadsContext.tsx so
callers receive the propagated error and UI reconciliation can fail fast.

Comment on lines +52 to +60
if (refreshInFlightRef.current) {
return refreshInFlightRef.current;
}
setIsLoading(true);
const promise = doFetch().finally(() => {
refreshInFlightRef.current = null;
});
refreshInFlightRef.current = promise;
return promise;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Coalescing can return stale thread lists after mid-flight mutations.

If a second refresh() is requested while one is in flight, you return the old promise and skip a trailing fetch. A mutation that completes during that window can be missed until some later trigger.

💡 Proposed fix (queue one trailing refresh)
   const refreshInFlightRef = useRef<Promise<void> | null>(null);
+  const refreshQueuedRef = useRef(false);

   const refresh = useCallback(async () => {
     // Coalesce concurrent refresh calls into a single in-flight request.
     if (refreshInFlightRef.current) {
+      refreshQueuedRef.current = true;
       return refreshInFlightRef.current;
     }
-    setIsLoading(true);
-    const promise = doFetch().finally(() => {
-      refreshInFlightRef.current = null;
-    });
-    refreshInFlightRef.current = promise;
-    return promise;
+    do {
+      refreshQueuedRef.current = false;
+      setIsLoading(true);
+      const promise = doFetch().finally(() => {
+        refreshInFlightRef.current = null;
+      });
+      refreshInFlightRef.current = promise;
+      await promise;
+    } while (refreshQueuedRef.current);
   }, [doFetch]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/lib/threads/ThreadsContext.tsx` around lines 52 - 60, The current
refresh() coalescing returns the in-flight promise (refreshInFlightRef) and
skips any trailing fetch, so mid-flight mutations can be missed; change
refresh() to, when refreshInFlightRef.current exists, set a pendingRefreshRef
(e.g., pendingRefreshRef.current = true) and attach logic to the in-flight
promise's finally (or then) to, after it clears refreshInFlightRef, check
pendingRefreshRef and run doFetch() once more (resetting pendingRefreshRef) so
one trailing refresh is queued; keep setIsLoading semantics and ensure refresh()
still returns the original in-flight promise while scheduling the trailing
fetch.

Comment on lines +59 to +66
useEffect(() => {
if (!threadId) {
// Clear state when no thread is selected.
setMessages([]);
setError(null);
setIsLoading(false);
return;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Increment fetch sequence when threadId becomes null to prevent stale repopulation.

If deselection happens mid-fetch, the in-flight response still matches the current sequence and can repopulate messages after you've cleared state. Bump the sequence in this branch too so old responses are discarded.

💡 Suggested fix
   useEffect(() => {
     if (!threadId) {
+      // Invalidate any in-flight request for the previously selected thread.
+      fetchSeqRef.current += 1;
       // Clear state when no thread is selected.
       setMessages([]);
       setError(null);
       setIsLoading(false);
       return;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (!threadId) {
// Clear state when no thread is selected.
setMessages([]);
setError(null);
setIsLoading(false);
return;
}
useEffect(() => {
if (!threadId) {
// Invalidate any in-flight request for the previously selected thread.
fetchSeqRef.current += 1;
// Clear state when no thread is selected.
setMessages([]);
setError(null);
setIsLoading(false);
return;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/lib/threads/useThreadMessages.ts` around lines 59 - 66, When threadId
becomes falsy in the useEffect block, increment the fetch sequence token used to
validate in-flight responses (the same sequence checked when resolving async
fetches) so any pending responses are treated as stale; in the branch that calls
setMessages([]), setError(null), and setIsLoading(false) also bump the sequence
(e.g., increment fetchSequence or fetchSequenceRef.current) to ensure old fetch
handlers do not repopulate messages.

Comment on lines +234 to +254
// Cold-boot resume: once the thread list is loaded, ensure a thread is selected.
// Priority: URL param `t=` (already read by useSelectedThread) > most-recent thread.
// If the URL has a valid t= that exists in the list, keep it. Otherwise fall back
// to the first thread (most recent after sort). If list is empty, create a new thread.
// useThreadMessages auto-fetches whenever selectedThreadId changes.
//
// NOTE: We only run this once when `threads` first becomes non-empty.
// Subsequent thread list refreshes (e.g. after delete) should not override
// a user's explicit selection — those are handled inline in the delete handler.
const threadListSettledRef = useRef(false);
useEffect(() => {
let cancelled = false;

void dispatch(loadThreads())
.unwrap()
.then(data => {
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
// if (cancelled || skipInitialThreadSelectionRef.current) return;
if (cancelled) return;
// [#1123] Commented out — welcome-agent onboarding replaced by Joyride walkthrough
// Always prefer the welcome thread during lockdown regardless of
// whether the server list is empty or not. Without this guard the
// stale `.then` could select a pre-existing thread from a prior
// session and pull the user out of the welcome conversation.
// const snapForSelect = getCoreStateSnapshot().snapshot;
// const threadStateForSelect = store.getState().thread;
// if (isWelcomeLocked(snapForSelect) && threadStateForSelect.welcomeThreadId) {
// dispatch(setSelectedThread(threadStateForSelect.welcomeThreadId));
// void dispatch(loadThreadMessages(threadStateForSelect.welcomeThreadId));
// return;
// }
const threadStateForSelect = store.getState().thread;
if (data.threads.length > 0) {
// Prefer the thread the user was last viewing (persisted across
// reloads via redux-persist on the `thread` slice). Only fall
// through to "most recent" if that thread no longer exists
// server-side (deleted, purged, or different user).
const persistedId = threadStateForSelect.selectedThreadId;
const resumeId =
persistedId && data.threads.some(t => t.id === persistedId)
? persistedId
: data.threads[0].id;
dispatch(setSelectedThread(resumeId));
void dispatch(loadThreadMessages(resumeId));
} else {
void handleCreateNewThread();
}
});

return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch]);

useEffect(() => {
if (selectedThreadId) {
void dispatch(loadThreadMessages(selectedThreadId));
if (threadListSettledRef.current) return;
if (threads.length > 0) {
threadListSettledRef.current = true;
// selectedThreadId from URL is already restored on cold boot — validate it still exists.
if (selectedThreadId && threads.some(t => t.id === selectedThreadId)) return;
// URL param missing or points to a deleted/purged thread — pick the first available.
setSelectedThreadId(threads[0].id);
}
}, [selectedThreadId, dispatch]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [threads]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep selectedThreadId valid after list refreshes and empty states.

This one-shot guard means a fresh account or any later refresh that drops the current thread can leave selectedThreadId null/stale forever. The composer still looks enabled, but handleSendMessage() just returns on Line 468. Revalidate the selection whenever threads changes, and either auto-create a thread or explicitly block the composer while no thread exists.

💡 One localized way to close the gap
-  const threadListSettledRef = useRef(false);
   useEffect(() => {
-    if (threadListSettledRef.current) return;
-    if (threads.length > 0) {
-      threadListSettledRef.current = true;
-      // selectedThreadId from URL is already restored on cold boot — validate it still exists.
-      if (selectedThreadId && threads.some(t => t.id === selectedThreadId)) return;
-      // URL param missing or points to a deleted/purged thread — pick the first available.
-      setSelectedThreadId(threads[0].id);
-    }
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [threads]);
+    if (threads.length === 0) {
+      setSelectedThreadId(null);
+      return;
+    }
+    if (selectedThreadId && threads.some(t => t.id === selectedThreadId)) return;
+    setSelectedThreadId(threads[0].id);
+  }, [threads, selectedThreadId, setSelectedThreadId]);
...
-  const composerInteractionBlocked = isComposerInteractionBlocked({
-    activeThreadId,
-    welcomePending: false,
-    rustChat,
-  });
+  const composerInteractionBlocked =
+    !selectedThreadId ||
+    isComposerInteractionBlocked({
+      activeThreadId,
+      welcomePending: false,
+      rustChat,
+    });

Also applies to: 464-468

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/pages/Conversations.tsx` around lines 234 - 254, The one-shot guard
using threadListSettledRef inside the useEffect can leave selectedThreadId stale
when threads later become empty or change; update the logic in the effect that
watches threads (and any related startup effect) to always revalidate
selectedThreadId on every threads change: if threads.length > 0 ensure
selectedThreadId exists in threads and call setSelectedThreadId(threads[0].id)
when missing, and if threads.length === 0 either programmatically create a new
thread (invoke the existing create/thread-creation function) or set a clear
"no-thread" state that disables the composer; also audit handleSendMessage to
proactively create a thread when selectedThreadId is null before returning
(reference: threadListSettledRef, selectedThreadId, setSelectedThreadId,
useEffect watching threads, and handleSendMessage).

Comment thread app/src/providers/ChatRuntimeProvider.tsx
… resume

Rust core (the real fix): web channel's build_session_agent was calling
set_agent_definition_name AFTER Agent::from_config_for_agent, but
session_key is frozen during .build() — so transcript files were written
as {ts}_orchestrator.jsonl regardless of thread, and the resume scan
(which looks for files matching the post-rename, thread-scoped name)
never found a match. Result: every cache-miss continuation (process
restart, mid-turn cancel, anything that evicts THREAD_SESSIONS) started
the agent with empty history, so the model "forgot" prior context even
though the JSONL was on disk.

Add Agent::from_config_for_agent_with_name so the per-thread name flows
to the builder before .build(); web.rs now uses it. Regression test
locks the contract that two definition_names in the same workspace
resume independently.

Frontend: pull threads/messages fresh from core on every selection and
re-pull after each chat_done event. selectedThreadId moves to a URL
hash query param so it survives reload without redux-persist.
chatRuntimeSlice (livestream preview / tool timeline) is unchanged.
The 370-line threadSlice and its persist config are deleted.
@senamakel senamakel force-pushed the refactor/threads-rust-source-of-truth branch from 3be500a to 948f055 Compare May 5, 2026 03:08
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (3)
app/src/lib/threads/useThreadMessages.ts (1)

59-65: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Invalidate in-flight fetches when threadId is cleared.

This branch clears UI state but leaves fetchSeqRef unchanged, so a response from the previously selected thread can still land afterward and repopulate messages for a deselected thread. Bump the sequence before returning here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/lib/threads/useThreadMessages.ts` around lines 59 - 65, When threadId
is falsy the effect clears UI state but doesn't invalidate pending fetches;
increment fetchSeqRef (the sequence used to discard stale fetch results) before
returning so any in-flight responses for the previous thread are ignored —
update the useEffect handling of threadId to bump fetchSeqRef.current (or call
the existing bump method) prior to calling setMessages([]), setError(null),
setIsLoading(false) and returning.
app/src/providers/ChatRuntimeProvider.tsx (1)

599-615: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Refresh the thread list after these direct JSONL appends.

Both success paths invalidate useThreadMessages(), but neither invalidates the threads list. That leaves sidebar metadata and ordering stale after proactive/error messages until another code path happens to refresh threads. Trigger the existing thread-list refresh event (or useThreads().refresh()) after each successful appendMessage(...).

Also applies to: 713-731

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/providers/ChatRuntimeProvider.tsx` around lines 599 - 615, The
proactive/error message append paths call threadApi.appendMessage(...) and
notifyThreadMessagesRefresh(...) but do not refresh the threads list; update
both places (the appendMessage success handlers around the proactiveMsg and the
block at lines 713-731) to trigger the threads-list refresh after a successful
append — e.g., call the existing thread-list refresh mechanism (invoke
useThreads().refresh() or the equivalent notifyThreadsRefresh/rtLog event that
your app uses) using the targetThreadId so the sidebar metadata and ordering are
updated immediately after appendMessage completes.
app/src/pages/Conversations.tsx (1)

243-254: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Revalidate the selected thread on every list refresh.

threadListSettledRef still makes this a one-shot check, so later refreshes can leave selectedThreadId null or pointing at a deleted thread. On a fresh/emptied list that turns into a silent no-op send path because handleSendMessage() bails when no valid thread is selected. Handle threads.length === 0 explicitly and keep revalidating the URL selection whenever threads changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/pages/Conversations.tsx` around lines 243 - 254, threadListSettledRef
makes the validation a one-shot so later updates to threads can leave
selectedThreadId stale; update the useEffect that depends on threads (the block
referencing threadListSettledRef, threads, selectedThreadId, and
setSelectedThreadId) to always revalidate on every threads change: explicitly
handle threads.length === 0 by clearing selection (call
setSelectedThreadId(null) or equivalent), and if threads.length > 0 check if
selectedThreadId exists in threads and if not
setSelectedThreadId(threads[0].id); remove or reset the one-time
threadListSettledRef guard so the effect runs fully on each threads update.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/src/lib/threads/useThreadMessages.ts`:
- Around line 72-75: In useThreadMessages, remove the synchronous calls to
setMessages([]), setError(null), and setIsLoading(true) from the top level of
the effect body and instead perform the "clear + set loading" transition inside
the async fetch task (e.g., an async IIFE or fetchMessages function) so the
state updates occur asynchronously (or dispatch a RESET action if you switch to
useReducer); specifically, delete the three setters from the effect start and
invoke them from within the async function just before the network call (or
dispatch RESET there) so the lint rule react-hooks/set-state-in-effect is not
violated.

---

Duplicate comments:
In `@app/src/lib/threads/useThreadMessages.ts`:
- Around line 59-65: When threadId is falsy the effect clears UI state but
doesn't invalidate pending fetches; increment fetchSeqRef (the sequence used to
discard stale fetch results) before returning so any in-flight responses for the
previous thread are ignored — update the useEffect handling of threadId to bump
fetchSeqRef.current (or call the existing bump method) prior to calling
setMessages([]), setError(null), setIsLoading(false) and returning.

In `@app/src/pages/Conversations.tsx`:
- Around line 243-254: threadListSettledRef makes the validation a one-shot so
later updates to threads can leave selectedThreadId stale; update the useEffect
that depends on threads (the block referencing threadListSettledRef, threads,
selectedThreadId, and setSelectedThreadId) to always revalidate on every threads
change: explicitly handle threads.length === 0 by clearing selection (call
setSelectedThreadId(null) or equivalent), and if threads.length > 0 check if
selectedThreadId exists in threads and if not
setSelectedThreadId(threads[0].id); remove or reset the one-time
threadListSettledRef guard so the effect runs fully on each threads update.

In `@app/src/providers/ChatRuntimeProvider.tsx`:
- Around line 599-615: The proactive/error message append paths call
threadApi.appendMessage(...) and notifyThreadMessagesRefresh(...) but do not
refresh the threads list; update both places (the appendMessage success handlers
around the proactiveMsg and the block at lines 713-731) to trigger the
threads-list refresh after a successful append — e.g., call the existing
thread-list refresh mechanism (invoke useThreads().refresh() or the equivalent
notifyThreadsRefresh/rtLog event that your app uses) using the targetThreadId so
the sidebar metadata and ordering are updated immediately after appendMessage
completes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7423d129-ffe3-40e0-a68f-731a6a0e2672

📥 Commits

Reviewing files that changed from the base of the PR and between 3be500a and 948f055.

⛔ Files ignored due to path filters (2)
  • Cargo.lock is excluded by !**/*.lock
  • app/src-tauri/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (24)
  • app/src/App.tsx
  • app/src/lib/threads/ThreadsContext.tsx
  • app/src/lib/threads/__tests__/ThreadsContext.test.tsx
  • app/src/lib/threads/__tests__/messagesRefreshBus.test.ts
  • app/src/lib/threads/__tests__/useSelectedThread.test.tsx
  • app/src/lib/threads/__tests__/useThreadMessages.test.tsx
  • app/src/lib/threads/messagesRefreshBus.ts
  • app/src/lib/threads/useActiveThread.tsx
  • app/src/lib/threads/useSelectedThread.ts
  • app/src/lib/threads/useThreadMessages.ts
  • app/src/pages/Conversations.tsx
  • app/src/pages/__tests__/Conversations.render.test.tsx
  • app/src/pages/conversations/components/WorkerThreadRefCard.tsx
  • app/src/pages/onboarding/__tests__/OnboardingLayout.test.tsx
  • app/src/providers/ChatRuntimeProvider.tsx
  • app/src/providers/CoreStateProvider.tsx
  • app/src/providers/__tests__/ChatRuntimeProvider.test.tsx
  • app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx
  • app/src/store/__tests__/threadSlice.test.ts
  • app/src/store/index.ts
  • app/src/store/threadSlice.ts
  • src/openhuman/agent/harness/session/builder.rs
  • src/openhuman/agent/harness/session/turn_tests.rs
  • src/openhuman/channels/providers/web.rs
💤 Files with no reviewable changes (3)
  • app/src/store/index.ts
  • app/src/store/threadSlice.ts
  • app/src/store/tests/threadSlice.test.ts
✅ Files skipped from review due to trivial changes (4)
  • app/src/lib/threads/useActiveThread.tsx
  • app/src/pages/onboarding/tests/OnboardingLayout.test.tsx
  • app/src/providers/CoreStateProvider.tsx
  • app/src/lib/threads/ThreadsContext.tsx
🚧 Files skipped from review as they are similar to previous changes (6)
  • app/src/pages/conversations/components/WorkerThreadRefCard.tsx
  • app/src/lib/threads/messagesRefreshBus.ts
  • app/src/lib/threads/tests/useSelectedThread.test.tsx
  • app/src/providers/tests/CoreStateProvider.identityFlip.test.tsx
  • app/src/lib/threads/tests/useThreadMessages.test.tsx
  • app/src/pages/tests/Conversations.render.test.tsx

Comment on lines +72 to +75
// Clear immediately before fetching so no stale messages flash.
setMessages([]);
setError(null);
setIsLoading(true);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

This reset-before-fetch pattern will trip react-hooks/set-state-in-effect.

The effect synchronously calls setMessages, setError, and setIsLoading before kicking off the fetch. This repo explicitly enforces that lint rule, so this hook needs a different state shape/flow for the cleared-loading transition instead of setting state directly in the effect body. Based on learnings, “do not perform synchronous setState (or other state-updating calls) directly inside useEffect bodies… The lint rule react-hooks/set-state-in-effect (enforced in this codebase) disallows patterns such as resetting state flags at the top of an effect before returning.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/lib/threads/useThreadMessages.ts` around lines 72 - 75, In
useThreadMessages, remove the synchronous calls to setMessages([]),
setError(null), and setIsLoading(true) from the top level of the effect body and
instead perform the "clear + set loading" transition inside the async fetch task
(e.g., an async IIFE or fetchMessages function) so the state updates occur
asynchronously (or dispatch a RESET action if you switch to useReducer);
specifically, delete the three setters from the effect start and invoke them
from within the async function just before the network call (or dispatch RESET
there) so the lint rule react-hooks/set-state-in-effect is not violated.

@senamakel senamakel closed this May 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant