Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 0 additions & 19 deletions app/src/components/BottomTabBar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useLocation, useNavigate } from 'react-router-dom';

import { useCoreState } from '../providers/CoreStateProvider';
import { useAppSelector } from '../store/hooks';

const tabs = [
{
Expand Down Expand Up @@ -108,16 +107,6 @@ const BottomTabBar = () => {
const { snapshot } = useCoreState();
const token = snapshot.sessionToken;

const conversationsUnreadCount = useAppSelector(state => {
const { threads, lastViewedAt } = state.thread;
if (threads.length === 0) return 0;
return threads.filter(t => {
const viewed = lastViewedAt[t.id];
const lastMsg = new Date(t.lastMessageAt || t.createdAt).getTime();
return viewed == null || lastMsg > viewed;
}).length;
});

const hiddenPaths = ['/', '/login'];
if (
!token ||
Expand Down Expand Up @@ -146,7 +135,6 @@ const BottomTabBar = () => {
<nav className="pointer-events-auto inline-flex items-center gap-2 rounded-sm border border-stone-300 bg-stone-200 shadow-soft px-1 py-1">
{tabs.map(tab => {
const active = isActive(tab.path);
const showBadge = tab.id === 'chat' && conversationsUnreadCount > 0;
return (
<button
key={tab.id}
Expand All @@ -159,13 +147,6 @@ const BottomTabBar = () => {
aria-label={tab.label}>
{tab.icon}
<span>{tab.label}</span>
{showBadge && (
<span
className="absolute -top-1 left-5 min-w-[16px] h-[16px] px-1 flex items-center justify-center rounded-full bg-coral-500 text-white text-[9px] font-medium"
aria-label={`${conversationsUnreadCount} unread`}>
{conversationsUnreadCount > 99 ? '99+' : conversationsUnreadCount}
</span>
)}
</button>
);
})}
Expand Down
168 changes: 148 additions & 20 deletions app/src/pages/Conversations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ import { useAppDispatch, useAppSelector } from '../store/hooks';
import { selectSocketStatus } from '../store/socketSelectors';
import {
addMessageLocal,
createThreadLocal,
createNewThread,
deleteThread,
fetchSuggestedQuestions,
loadThreadMessages,
loadThreads,
persistReaction,
setActiveThread,
setLastViewed,
setSelectedThread,
} from '../store/threadSlice';
import type { ThreadMessage } from '../types/thread';
Expand All @@ -38,8 +38,6 @@ import {
openhumanVoiceTts,
} from '../utils/tauriCommands';

const DEFAULT_THREAD_ID = 'default-thread';
const DEFAULT_THREAD_TITLE = 'Conversation';
const AGENTIC_MODEL_ID = 'agentic-v1';
/** Maximum trailing characters rendered in the live-streaming assistant
* preview bubble. The full response is revealed via `addInferenceResponse`
Expand Down Expand Up @@ -150,6 +148,7 @@ const Conversations = () => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const {
threads,
selectedThreadId,
messages,
isLoadingMessages,
Expand All @@ -159,6 +158,7 @@ const Conversations = () => {
activeThreadId,
} = useAppSelector(state => state.thread);

const [showSidebar, setShowSidebar] = useState(true);
const [inputValue, setInputValue] = useState('');
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
const [inputMode, setInputMode] = useState<InputMode>('text');
Expand Down Expand Up @@ -222,23 +222,26 @@ const Conversations = () => {
typeof navigator.mediaDevices !== 'undefined' &&
typeof navigator.mediaDevices.getUserMedia === 'function';

useEffect(() => {
void dispatch(loadThreads());
void dispatch(
createThreadLocal({
id: DEFAULT_THREAD_ID,
title: DEFAULT_THREAD_TITLE,
createdAt: new Date().toISOString(),
})
).then(() => {
dispatch(setSelectedThread(DEFAULT_THREAD_ID));
void dispatch(loadThreadMessages(DEFAULT_THREAD_ID));
});
}, [dispatch]);
const handleCreateNewThread = async () => {
const thread = await dispatch(createNewThread()).unwrap();
dispatch(setSelectedThread(thread.id));
void dispatch(loadThreadMessages(thread.id));
};

useEffect(() => {
if (selectedThreadId) dispatch(setLastViewed(selectedThreadId));
}, [selectedThreadId, dispatch]);
void dispatch(loadThreads())
.unwrap()
.then(data => {
if (data.threads.length > 0) {
const mostRecent = data.threads[0];
dispatch(setSelectedThread(mostRecent.id));
void dispatch(loadThreadMessages(mostRecent.id));
} else {
void handleCreateNewThread();
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch]);

Comment on lines +227 to 247
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

Handle bootstrap/create failures instead of dropping the promise.

Both dispatch(loadThreads()).unwrap() and dispatch(createNewThread()).unwrap() can reject, but this flow launches them fire-and-forget and never catches the error. A backend failure here becomes an unhandled promise rejection and can leave the page with no selected thread or user feedback.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/pages/Conversations.tsx` around lines 225 - 245, The current
bootstrap uses dispatch(loadThreads()).unwrap() and
dispatch(createNewThread()).unwrap() fire-and-forget which can reject and cause
unhandled promise rejections; update handleCreateNewThread and the useEffect
flow to properly await and catch errors: wrap the await
dispatch(createNewThread()).unwrap() in try/catch inside handleCreateNewThread
and surface failures (e.g., show error UI or retry) instead of dropping the
promise, and in the useEffect replace the chained .then with an async IIFE or
promise chain that awaits dispatch(loadThreads()).unwrap() inside try/catch,
handle the empty-threads case by calling the try/catch-protected
handleCreateNewThread, and ensure any calls to setSelectedThread and
loadThreadMessages are only executed after successful dispatch results so errors
are not swallowed.

useEffect(() => {
if (selectedThreadId) {
Expand Down Expand Up @@ -376,11 +379,24 @@ const Conversations = () => {
};
}, [inputMode, rustChat]);

const handleSlashCommand = (command: string): boolean => {
const cmd = command.toLowerCase();
if (cmd === '/new' || cmd === '/clear') {
setInputValue('');
void handleCreateNewThread();
return true;
}
return false;
Comment on lines +384 to +391
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

/clear shouldn't create a new thread.

This block treats /new and /clear identically, so /clear currently discards the draft and opens another conversation. Keep /clear local to the composer and reserve thread creation for /new.

Suggested fix
   const handleSlashCommand = (command: string): boolean => {
     const cmd = command.toLowerCase();
-    if (cmd === '/new' || cmd === '/clear') {
+    if (cmd === '/clear') {
+      setInputValue('');
+      return true;
+    }
+    if (cmd === '/new') {
       setInputValue('');
       void handleCreateNewThread();
       return true;
     }
     return false;
   };
📝 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
const handleSlashCommand = (command: string): boolean => {
const cmd = command.toLowerCase();
if (cmd === '/new' || cmd === '/clear') {
setInputValue('');
void handleCreateNewThread();
return true;
}
return false;
const handleSlashCommand = (command: string): boolean => {
const cmd = command.toLowerCase();
if (cmd === '/clear') {
setInputValue('');
return true;
}
if (cmd === '/new') {
setInputValue('');
void handleCreateNewThread();
return true;
}
return false;
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/pages/Conversations.tsx` around lines 382 - 389, The slash-command
handler wrongly treats '/clear' like '/new' and calls handleCreateNewThread;
update handleSlashCommand so that cmd === '/new' clears input and calls
handleCreateNewThread(), while cmd === '/clear' only calls setInputValue('') (no
thread creation). Locate handleSlashCommand and modify the branching to separate
'/new' and '/clear' cases, keeping return true for both when handled.

};

const handleSendMessage = async (text?: string) => {
const normalized = text ?? inputValue;
const trimmed = normalized.trim();

if (!trimmed || !selectedThreadId || composerBlocked) return;

if (handleSlashCommand(trimmed)) return;

if (isAtLimit) {
setShowLimitModal(true);
setSendError(
Expand Down Expand Up @@ -685,9 +701,121 @@ const Conversations = () => {
inferenceTurnLifecycleByThread[selectedThreadId] === 'streaming')
);

const sortedThreads = [...threads].sort(
(a, b) => new Date(b.lastMessageAt).getTime() - new Date(a.lastMessageAt).getTime()
);

return (
<div className="h-full relative z-10 flex justify-center overflow-hidden p-4 pt-6">
<div className="h-full relative z-10 flex overflow-hidden p-4 pt-6 gap-3">
{/* Thread sidebar */}
{showSidebar && (
<div className="w-64 flex-shrink-0 flex flex-col bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-stone-100">
<h2 className="text-sm font-semibold text-stone-700">Threads</h2>
<button
onClick={() => void handleCreateNewThread()}
className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-stone-100 text-stone-500 hover:text-stone-700 transition-colors"
title="New thread">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto">
{sortedThreads.length === 0 ? (
<p className="px-4 py-6 text-xs text-stone-400 text-center">No threads yet</p>
) : (
sortedThreads.map(thread => (
<button
key={thread.id}
onClick={() => {
dispatch(setSelectedThread(thread.id));
void dispatch(loadThreadMessages(thread.id));
}}
className={`w-full text-left px-4 py-3 border-b border-stone-50 transition-colors group ${
selectedThreadId === thread.id
? 'bg-primary-50 border-l-2 border-l-primary-500'
: 'hover:bg-stone-50'
}`}>
<div className="flex items-center justify-between">
<p
className={`text-sm truncate flex-1 ${
selectedThreadId === thread.id
? 'font-medium text-primary-700'
: 'text-stone-700'
}`}>
{thread.title}
</p>
<button
onClick={e => {
e.stopPropagation();
void dispatch(deleteThread(thread.id));
}}
Comment on lines +757 to +760
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 | 🟡 Minor

Consider handling delete errors.

The delete action fires-and-forgets without user feedback on failure. For destructive operations, users should know if deletion failed.

Suggested improvement
  onClick={e => {
    e.stopPropagation();
-   void dispatch(deleteThread(thread.id));
+   void dispatch(deleteThread(thread.id))
+     .unwrap()
+     .catch(err => {
+       console.error('[threads] delete failed', err);
+       setSendError(chatSendError('thread_delete_failed', 'Failed to delete thread.'));
+     });
  }}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/pages/Conversations.tsx` around lines 757 - 760, The onClick handler
currently fire-and-forgets dispatch(deleteThread(thread.id)); update it to
handle errors and surface feedback: await the dispatched thunk or return promise
from deleteThread, catch failures, and show a user-visible error (e.g., toast or
modal) and/or rollback any optimistic UI changes; ensure the onClick uses
async/await (or .then/.catch) and references deleteThread and thread.id so the
UI only confirms deletion on success and displays an error message on failure.

className="ml-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-stone-200 text-stone-400 hover:text-coral-500 transition-all flex-shrink-0"
title="Delete thread">
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-[10px] text-stone-400">
{formatRelativeTime(thread.lastMessageAt)}
</span>
{thread.messageCount > 0 && (
<span className="text-[10px] text-stone-400">
{thread.messageCount} msg{thread.messageCount !== 1 ? 's' : ''}
</span>
)}
</div>
</button>
Comment on lines +735 to +787
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

Avoid nesting the delete button inside the thread row button.

A <button> inside another <button> is invalid HTML and breaks keyboard/screen-reader behavior. Split the row into a non-button container with separate controls, or move the delete action outside the clickable thread button.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/pages/Conversations.tsx` around lines 733 - 785, The outer clickable
thread row must not be a <button> containing another <button>; replace the outer
<button> (the element rendering each item in sortedThreads that calls
dispatch(setSelectedThread(thread.id)) and loadThreadMessages(thread.id)) with a
non-button container (e.g., a <div> or <li>) that has role="button",
tabIndex={0}, and an onClick handler invoking those dispatches, and add an
onKeyDown that triggers the same behavior for Enter/Space so keyboard users
work. Keep the inner delete control as a real <button> that calls
deleteThread(thread.id), preserve the className/styles (including
selectedThreadId checks and formatRelativeTime(thread.lastMessageAt) output),
and ensure the container still has the same visual/interactive states
(hover/selected styles) so behavior and accessibility are fixed.

))
)}
</div>
</div>
)}

{/* Main chat area */}
<div className="flex-1 flex flex-col min-w-0 max-w-2xl bg-white rounded-2xl shadow-soft border border-stone-200 overflow-hidden">
{/* Chat header */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-stone-100">
<button
onClick={() => setShowSidebar(prev => !prev)}
className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-stone-100 text-stone-500 hover:text-stone-700 transition-colors"
title={showSidebar ? 'Hide sidebar' : 'Show sidebar'}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<h3 className="text-sm font-medium text-stone-700 truncate flex-1">
{threads.find(t => t.id === selectedThreadId)?.title ?? 'Select a thread'}
</h3>
<button
onClick={() => void handleCreateNewThread()}
className="px-2.5 py-1 rounded-lg text-xs font-medium text-primary-600 hover:bg-primary-50 transition-colors"
title="New thread (/new)">
+ New
</button>
</div>
<div className="flex-1 overflow-y-auto px-5 py-4 bg-stone-50">
{isLoadingMessages ? (
<div className="space-y-4">
Expand Down
8 changes: 4 additions & 4 deletions app/src/services/api/threadApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ describe('threadApi', () => {
mockCallCoreRpc.mockReset();
});

it('loads threads from the memory RPC store', async () => {
it('loads threads from the threads RPC store', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
data: {
threads: [
Expand All @@ -32,12 +32,12 @@ describe('threadApi', () => {
const { threadApi } = await import('./threadApi');
const result = await threadApi.getThreads();

expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.memory_threads_list' });
expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.threads_list' });
expect(result.count).toBe(1);
expect(result.threads[0].id).toBe('default-thread');
});

it('appends a message via memory RPC', async () => {
it('appends a message via threads RPC', async () => {
const message = {
id: 'm1',
content: 'hello',
Expand All @@ -52,7 +52,7 @@ describe('threadApi', () => {
const result = await threadApi.appendMessage('default-thread', message);

expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.memory_message_append',
method: 'openhuman.threads_message_append',
params: { thread_id: 'default-thread', message },
});
expect(result).toEqual(message);
Expand Down
Loading
Loading