Skip to content

Commit c26ca79

Browse files
M3gA-Mindclaude
andauthored
fix: keep chat processing alive across tab switches (tinyhumansai#587)
* fix(chat): keep in-flight responses alive across tab switches Made-with: Cursor * chore: satisfy lint and format push gates Made-with: Cursor * fix: address CodeRabbit review on chat runtime PR Made-with: Cursor * feat(chat): add explicit inference turn lifecycle in chat runtime slice Made-with: Cursor * feat(chat): implement thinking summary feature in chat responses Added a new mechanism to accumulate and send model reasoning text as a separate message during chat interactions. This includes handling "thinking_delta" events to gather reasoning content, formatting it for clarity, and ensuring it is sent before the main response. Updated the StreamingState struct to include a thinking accumulator for this purpose. This enhancement improves user experience by providing insight into the model's reasoning process. * feat(telegram): update bot username handling for staging and production environments Refactored the Telegram bot username resolution logic to differentiate between staging and production environments. Introduced constants for default usernames based on the application environment and updated the GitHub Actions workflow to set the appropriate environment variables. This change enhances the flexibility and clarity of bot username management in the application. * fix: resolve CodeRabbit review issues on feat/thinking-telegram-summary - bus.rs: fix UTF-8 char boundary panic in format_thinking_summary truncation - threadSlice.ts: remove premature activeThreadId clear from addInferenceResponse.rejected - Conversations.tsx: add composerBlocked global lock, clear tool timeline in safety timeout - ChatRuntimeProvider.tsx: replace stale toolTimelineRef/inferenceStatusRef reads with live store.getState() calls in all event handlers - LocalAIDownloadSnackbar.tsx: reset dismissed/collapsed on not-downloading → downloading transition edge - MemoryGraphMap.tsx: derive activeSelectedNode to guard stale selectedNode after relations refresh; add debug logs to useMemo graph recompute Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: use render-phase update for download dismiss reset in LocalAIDownloadSnackbar Replace effect-based setState with the React render-phase update pattern so the not-downloading → downloading transition resets dismissed/collapsed without triggering the react-hooks/set-state-in-effect lint warning. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: apply prettier and cargo fmt auto-formatting Formatting changes applied by the pre-push hook during the previous commit. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: replace endInferenceTurn with clearRuntimeForThread in Conversations component Updated the Conversations component to replace the endInferenceTurn dispatch with clearRuntimeForThread. This change simplifies the handling of thread runtime state during error scenarios and timeout conditions, ensuring a cleaner state management approach. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a8664f0 commit c26ca79

17 files changed

Lines changed: 972 additions & 685 deletions

File tree

.github/workflows/release.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,9 @@ jobs:
263263
artifact_suffix: windows
264264
env:
265265
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
266+
# Keep in sync with DEFAULT_TELEGRAM_BOT_USERNAME_* in channels/controllers/ops.rs
267+
OPENHUMAN_TELEGRAM_BOT_USERNAME: ${{ inputs.build_target == 'staging' && 'alphahumantest_bot' || 'openhumanaibot' }}
268+
VITE_TELEGRAM_BOT_USERNAME: ${{ inputs.build_target == 'staging' && 'alphahumantest_bot' || 'openhumanaibot' }}
266269
steps:
267270
- name: Checkout build ref
268271
uses: actions/checkout@v4
@@ -277,6 +280,8 @@ jobs:
277280
run: |
278281
echo "OPENHUMAN_APP_ENV=staging" >> "$GITHUB_ENV"
279282
echo "VITE_OPENHUMAN_APP_ENV=staging" >> "$GITHUB_ENV"
283+
echo "OPENHUMAN_TELEGRAM_BOT_USERNAME=alphahumantest_bot" >> "$GITHUB_ENV"
284+
echo "VITE_TELEGRAM_BOT_USERNAME=alphahumantest_bot" >> "$GITHUB_ENV"
280285
281286
- name: Set Xcode version
282287
if: matrix.settings.platform == 'macos-latest'

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/src/App.tsx

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import MeshGradient from './components/MeshGradient';
1313
import OnboardingOverlay from './components/OnboardingOverlay';
1414
import RouteLoadingScreen from './components/RouteLoadingScreen';
1515
import GlobalUpsellBanner from './components/upsell/GlobalUpsellBanner';
16+
import ChatRuntimeProvider from './providers/ChatRuntimeProvider';
1617
import CoreStateProvider from './providers/CoreStateProvider';
1718
import SocketProvider from './providers/SocketProvider';
1819
import { tagErrorSource } from './services/errorReportQueue';
@@ -31,23 +32,25 @@ function App() {
3132
<PersistGate loading={<RouteLoadingScreen />} persistor={persistor}>
3233
<CoreStateProvider>
3334
<SocketProvider>
34-
<Router>
35-
<ServiceBlockingGate>
36-
<div className="relative h-screen flex flex-col overflow-hidden">
37-
<MeshGradient />
38-
<div className="app-dotted-canvas relative z-10 flex-1 flex flex-col overflow-hidden">
39-
<div className="flex-1 overflow-y-auto pb-16">
40-
<GlobalUpsellBanner />
41-
<AppRoutes />
35+
<ChatRuntimeProvider>
36+
<Router>
37+
<ServiceBlockingGate>
38+
<div className="relative h-screen flex flex-col overflow-hidden">
39+
<MeshGradient />
40+
<div className="app-dotted-canvas relative z-10 flex-1 flex flex-col overflow-hidden">
41+
<div className="flex-1 overflow-y-auto pb-16">
42+
<GlobalUpsellBanner />
43+
<AppRoutes />
44+
</div>
45+
<BottomTabBar />
4246
</div>
43-
<BottomTabBar />
4447
</div>
45-
</div>
46-
<OnboardingOverlay />
47-
<DictationHotkeyManager />
48-
<LocalAIDownloadSnackbar />
49-
</ServiceBlockingGate>
50-
</Router>
48+
<OnboardingOverlay />
49+
<DictationHotkeyManager />
50+
<LocalAIDownloadSnackbar />
51+
</ServiceBlockingGate>
52+
</Router>
53+
</ChatRuntimeProvider>
5154
</SocketProvider>
5255
</CoreStateProvider>
5356
</PersistGate>

app/src/components/LocalAIDownloadSnackbar.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ const LocalAIDownloadSnackbar = () => {
2929
const [dismissed, setDismissed] = useState(false);
3030
const [collapsed, setCollapsed] = useState(false);
3131
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);
32+
// Track previous isDownloading in state so we can reset the dismiss flag on a
33+
// not-downloading → downloading transition during render (render-phase update,
34+
// the officially recommended React pattern for adjusting state on derived-value changes).
35+
const [prevIsDownloading, setPrevIsDownloading] = useState(false);
3236

3337
// Check Tauri availability once at init
3438
const tauriAvailable = (() => {
@@ -72,15 +76,15 @@ const LocalAIDownloadSnackbar = () => {
7276
currentState === 'installing' ||
7377
(downloads?.progress != null && downloads.progress > 0 && downloads.progress < 1);
7478

75-
// Auto-show when a new download starts: track prior state in a ref and
76-
// reset dismissed on the transition edge (not-downloading → downloading).
77-
const wasDownloadingRef = useRef(false);
78-
useEffect(() => {
79-
if (isDownloading && !wasDownloadingRef.current && dismissed) {
79+
// Render-phase update: when a new download cycle starts (not-downloading → downloading),
80+
// reset the dismiss/collapsed flags so the snackbar reappears automatically.
81+
if (!!isDownloading !== prevIsDownloading) {
82+
setPrevIsDownloading(!!isDownloading);
83+
if (isDownloading && !prevIsDownloading) {
8084
setDismissed(false);
85+
setCollapsed(false);
8186
}
82-
wasDownloadingRef.current = !!isDownloading;
83-
}, [dismissed, isDownloading]);
87+
}
8488

8589
const handleDismiss = useCallback(() => setDismissed(true), []);
8690
const handleToggleCollapse = useCallback(() => setCollapsed(prev => !prev), []);

app/src/components/OnboardingOverlay.tsx

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useState } from 'react';
1+
import { useCallback, useEffect, useRef, useState } from 'react';
22
import { createPortal } from 'react-dom';
33
import { useNavigate } from 'react-router-dom';
44

@@ -17,26 +17,31 @@ const OnboardingOverlay = () => {
1717
const { isBootstrapping, snapshot, setOnboardingCompletedFlag } = useCoreState();
1818
const token = snapshot.sessionToken;
1919
const user = snapshot.currentUser;
20-
const [userLoadTimedOut, setUserLoadTimedOut] = useState(false);
20+
/** Which session token the 3s profile-timeout applied to (ref avoids stale boolean across logins). */
21+
const profileLoadTimedOutForTokenRef = useRef<string | null>(null);
22+
const [, profileTimeoutBump] = useState(0);
2123

22-
// Reset local state on logout so re-login starts fresh.
23-
useEffect(() => {
24-
if (!token) {
25-
setUserLoadTimedOut(false);
26-
}
27-
}, [token]);
24+
const prevTokenRef = useRef<string | null | undefined>(undefined);
25+
if (prevTokenRef.current !== token) {
26+
prevTokenRef.current = token;
27+
profileLoadTimedOutForTokenRef.current = null;
28+
}
2829

2930
// Timeout: if user profile hasn't loaded after 3s but we have token + bootstrap,
3031
// proceed anyway so onboarding isn't permanently invisible.
3132
useEffect(() => {
3233
if (!token || isBootstrapping || user?._id) return;
3334

34-
const timer = setTimeout(() => setUserLoadTimedOut(true), 3000);
35+
const timer = setTimeout(() => {
36+
profileLoadTimedOutForTokenRef.current = token;
37+
profileTimeoutBump(n => n + 1);
38+
}, 3000);
3539
return () => clearTimeout(timer);
3640
}, [token, isBootstrapping, user?._id]);
3741

38-
// User is ready when profile loaded or timeout elapsed.
39-
const userReady = !!user?._id || userLoadTimedOut;
42+
// User is ready when profile loaded or timeout elapsed for this session token.
43+
const userReady =
44+
!!user?._id || (token ? profileLoadTimedOutForTokenRef.current === token : false);
4045
const onboardingCompleted = snapshot.onboardingCompleted;
4146

4247
const handleDone = useCallback(async () => {

app/src/components/channels/DiscordServerChannelPicker.tsx

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,14 +39,6 @@ const DiscordServerChannelPicker = ({
3939
const channelsRequestIdRef = useRef(0);
4040
const permissionsRequestIdRef = useRef(0);
4141

42-
useEffect(() => {
43-
setSelectedGuildId(selectedGuildIdProp ?? '');
44-
}, [selectedGuildIdProp]);
45-
46-
useEffect(() => {
47-
setSelectedChannelId(selectedChannelIdProp ?? '');
48-
}, [selectedChannelIdProp]);
49-
5042
// Load guilds on mount
5143
useEffect(() => {
5244
const loadGuilds = async () => {

app/src/components/intelligence/MemoryGraphMap.tsx

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
import { useCallback, useEffect, useMemo, useState } from 'react';
1+
import debug from 'debug';
2+
import { useCallback, useMemo, useState } from 'react';
23

34
import type { GraphRelation } from '../../utils/tauriCommands';
45

6+
const log = debug('openhuman:memory-graph');
7+
58
interface MemoryGraphMapProps {
69
relations: GraphRelation[];
710
loading?: boolean;
@@ -161,18 +164,16 @@ function runSimulation(nodes: GraphNode[], edges: GraphEdge[], iterations = 150)
161164
}
162165

163166
export function MemoryGraphMap({ relations, loading }: MemoryGraphMapProps) {
164-
const [nodes, setNodes] = useState<GraphNode[]>([]);
165-
const [edges, setEdges] = useState<GraphEdge[]>([]);
166167
const [hoveredEdge, setHoveredEdge] = useState<number | null>(null);
167168
const [selectedNode, setSelectedNode] = useState<string | null>(null);
168-
const [namespacePalette, setNamespacePalette] = useState<Map<string, string>>(new Map());
169169
// Build graph data from relations (synchronous, deterministic)
170-
const { initialNodes, initialEdges, palette } = useMemo(() => {
170+
const { nodes, edges, namespacePalette } = useMemo(() => {
171+
log('[memory-graph] recomputing graph relations=%d', relations.length);
171172
if (relations.length === 0) {
172173
return {
173-
initialNodes: [] as GraphNode[],
174-
initialEdges: [] as GraphEdge[],
175-
palette: new Map<string, string>(),
174+
nodes: [] as GraphNode[],
175+
edges: [] as GraphEdge[],
176+
namespacePalette: new Map<string, string>(),
176177
};
177178
}
178179
const { nodes: rawNodes, edges: rawEdges } = buildGraph(relations);
@@ -182,16 +183,10 @@ export function MemoryGraphMap({ relations, loading }: MemoryGraphMapProps) {
182183
p.set(ns, NAMESPACE_COLORS[i % NAMESPACE_COLORS.length]);
183184
});
184185
const simulated = runSimulation(rawNodes, rawEdges);
185-
return { initialNodes: simulated, initialEdges: rawEdges, palette: p };
186+
log('[memory-graph] graph built nodes=%d edges=%d', simulated.length, rawEdges.length);
187+
return { nodes: simulated, edges: rawEdges, namespacePalette: p };
186188
}, [relations]);
187189

188-
// Sync memo results into state (needed for interactive selection/hover)
189-
useEffect(() => {
190-
setNodes(initialNodes);
191-
setEdges(initialEdges);
192-
setNamespacePalette(palette);
193-
}, [initialNodes, initialEdges, palette]);
194-
195190
const getNodeColor = useCallback(
196191
(node: GraphNode): string => {
197192
const ns = node.namespace ?? '__none__';
@@ -202,15 +197,20 @@ export function MemoryGraphMap({ relations, loading }: MemoryGraphMapProps) {
202197

203198
const nodeMap = new Map(nodes.map(n => [n.id, n]));
204199

200+
// Guard against stale selectedNode — if the node no longer exists in the current
201+
// graph (e.g. after a relations refresh), treat it as deselected so no dimming occurs.
202+
const activeSelectedNode =
203+
selectedNode !== null && nodeMap.has(selectedNode) ? selectedNode : null;
204+
205205
const centerNodeId =
206206
nodes.find(n => n.id === 'user' || n.id === 'self' || n.id === 'you')?.id ??
207207
(nodes.length > 0 ? nodes[0].id : null);
208208

209209
// Connected node ids for selected highlight
210-
const connectedIds = selectedNode
210+
const connectedIds = activeSelectedNode
211211
? new Set(
212212
edges
213-
.filter(e => e.source === selectedNode || e.target === selectedNode)
213+
.filter(e => e.source === activeSelectedNode || e.target === activeSelectedNode)
214214
.flatMap(e => [e.source, e.target])
215215
)
216216
: null;
@@ -269,7 +269,9 @@ export function MemoryGraphMap({ relations, loading }: MemoryGraphMapProps) {
269269
if (!src || !tgt) return null;
270270

271271
const isHighlighted =
272-
selectedNode === null || edge.source === selectedNode || edge.target === selectedNode;
272+
activeSelectedNode === null ||
273+
edge.source === activeSelectedNode ||
274+
edge.target === activeSelectedNode;
273275

274276
const midX = (src.x + tgt.x) / 2;
275277
const midY = (src.y + tgt.y) / 2;
@@ -313,8 +315,8 @@ export function MemoryGraphMap({ relations, loading }: MemoryGraphMapProps) {
313315
const r = 8 + (node.connectionCount / maxConn) * 18;
314316
const color = getNodeColor(node);
315317
const isCenter = node.id === centerNodeId;
316-
const isSelected = selectedNode === node.id;
317-
const isDimmed = selectedNode !== null && !connectedIds?.has(node.id);
318+
const isSelected = activeSelectedNode === node.id;
319+
const isDimmed = activeSelectedNode !== null && !connectedIds?.has(node.id);
318320

319321
return (
320322
<g
@@ -323,7 +325,7 @@ export function MemoryGraphMap({ relations, loading }: MemoryGraphMapProps) {
323325
style={{ cursor: 'pointer' }}
324326
onClick={e => {
325327
e.stopPropagation();
326-
setSelectedNode(selectedNode === node.id ? null : node.id);
328+
setSelectedNode(activeSelectedNode === node.id ? null : node.id);
327329
}}>
328330
{(isCenter || isSelected) && (
329331
<circle r={r + 5} fill="none" stroke={color} strokeWidth={2} opacity={0.4} />

0 commit comments

Comments
 (0)