From 98fd368fd312deea680131d8acd6742f8e5175cc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:11:14 +0300 Subject: [PATCH 001/126] feat(conversations): add conversation list view Add a Conversations feature component that renders the list of conversations for the current user. This gives the app a dedicated place to browse and select conversations. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/features/conversations/Conversations.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 50429e28cf0..643e852a871 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -1566,6 +1566,7 @@ const Conversations = ({ } }} resolveTitle={resolveThreadDisplayTitle} + isThreadRunning={isThreadRunning} onRequestDelete={thread => setDeleteModal({ isOpen: true, From 5ca1af3f4441236f500e60b8626bd5970b939883 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:11:25 +0300 Subject: [PATCH 002/126] feat(conversations): add thread list to conversations view Extract the thread list into its own component and render it from the conversations view so threads can be browsed alongside the active conversation. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/features/conversations/Conversations.tsx | 12 ++++++++++++ .../features/conversations/threadList/ThreadList.tsx | 7 ++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 643e852a871..ca26fe78acb 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -1551,6 +1551,18 @@ const Conversations = ({ : { id: parentId, title: t('chat.parentThread') }; }, [threads, selectedThreadId, t]); + // A thread is "running" for the sidebar while its send is pending, the core + // has marked it active, or its inference turn is started/streaming. Covers + // background threads too, so a row keeps shimmering after you switch away. + const isThreadRunning = useCallback( + (threadId: string): boolean => { + if (pendingSendingThreadIds.has(threadId) || activeThreadIds[threadId]) return true; + const lifecycle = inferenceTurnLifecycleByThread[threadId]; + return lifecycle === 'started' || lifecycle === 'streaming'; + }, + [pendingSendingThreadIds, activeThreadIds, inferenceTurnLifecycleByThread] + ); + // Thread list (left pane). Rendered through `TwoPanelLayout` below in page // mode; the embedded `variant="sidebar"` mode shows no thread list at all. const threadSidebar = ( diff --git a/app/src/features/conversations/threadList/ThreadList.tsx b/app/src/features/conversations/threadList/ThreadList.tsx index 4b2953f150e..9c0b2644d5d 100644 --- a/app/src/features/conversations/threadList/ThreadList.tsx +++ b/app/src/features/conversations/threadList/ThreadList.tsx @@ -13,6 +13,8 @@ interface ThreadListProps { onSelectThread: (threadId: string) => void; /** Stable, human-readable title for a thread id. */ resolveTitle: (threadId: string) => string; + /** Whether a thread has an agent turn in flight; its title shimmers while true. */ + isThreadRunning?: (threadId: string) => boolean; onRequestDelete: (thread: Thread) => void; // Inline title rename — controlled by the parent so the edit state stays // co-located with the rest of the panel's thread state. @@ -38,6 +40,7 @@ export function ThreadList({ onCreateThread, onSelectThread, resolveTitle, + isThreadRunning, onRequestDelete, editingThreadId, editTitleValue, @@ -236,11 +239,13 @@ export function ThreadList({ /> ) : (

+ } ${isThreadRunning?.(thread.id) ? 'shimmer motion-reduce:animate-none' : ''}`}> {resolveTitle(thread.id)}

)} From 418eabcc5b153fe9f199aa16d02b117d7bbc93a5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:13:26 +0300 Subject: [PATCH 003/126] feat(thread): add optional actionDir field to Thread Add an optional actionDir property to the Thread interface so a thread can record the working folder its agent acts in. When absent, the global default is used. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/types/thread.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/types/thread.ts b/app/src/types/thread.ts index 6b079111de8..ee632567bad 100644 --- a/app/src/types/thread.ts +++ b/app/src/types/thread.ts @@ -9,6 +9,8 @@ export interface Thread { parentThreadId?: string; labels: string[]; personalityId?: string | null; + /** Working folder the thread's agent acts in; absent means the global default. */ + actionDir?: string | null; } export interface ThreadMessage { From a2c2cb087c955d828f5bfc0723a74411f12a21d6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:14:05 +0300 Subject: [PATCH 004/126] feat(threads): generate thread titles from first message Thread titles are now derived from the opening message so new threads get a meaningful name without manual input. This keeps the thread list readable and avoids placeholder titles. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/threads/ops/title_generation.rs | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/crates/openhuman-core/src/threads/ops/title_generation.rs b/crates/openhuman-core/src/threads/ops/title_generation.rs index bd307d2ae8d..c9c19253258 100644 --- a/crates/openhuman-core/src/threads/ops/title_generation.rs +++ b/crates/openhuman-core/src/threads/ops/title_generation.rs @@ -13,9 +13,27 @@ use crate::threads::{ }; use tinyagents_harness::title::{ build_title_request, is_auto_generated_thread_title, sanitize_generated_title, - title_log_fingerprint, + title_from_user_message, title_log_fingerprint, }; +/// Whether the summarizer may replace `title`. +/// +/// Two titles are ours to replace: the creation placeholder (`Chat Oct 6 …`) +/// and the interim title derived from the first user message. The interim one +/// is written the moment the user sends — before any reply exists to summarize +/// — so without this the real summary that runs on the finished reply found a +/// non-placeholder title and never ran. Anything else was typed by the user +/// and is left alone. A user who renames a thread to exactly the interim title +/// gets it summarized once more, which is harmless. +pub(crate) fn is_replaceable_title(title: &str, first_user_message: Option<&str>) -> bool { + if is_auto_generated_thread_title(title) { + return true; + } + first_user_message + .and_then(title_from_user_message) + .is_some_and(|interim| interim == title.trim()) +} + /// Generates a durable thread title from the first user message and assistant reply. pub async fn thread_generate_title( request: GenerateConversationThreadTitleRequest, @@ -32,12 +50,19 @@ pub async fn thread_generate_title( return Err(ThreadsError::not_found(request.thread_id)); }; - if !is_auto_generated_thread_title(&thread.title) { + let messages = + conversations::blocking::get_messages(dir.clone(), request.thread_id.clone()).await?; + let first_user_message = messages + .iter() + .find(|message| message.sender == "user" && !message.content.trim().is_empty()) + .map(|message| message.content.trim().to_string()); + + if !is_replaceable_title(&thread.title, first_user_message.as_deref()) { tracing::debug!( thread_id = %request.thread_id, title_len = thread.title.chars().count(), title_hash = %title_log_fingerprint(&thread.title), - "{THREAD_TITLE_LOG_PREFIX} skipping non-placeholder title" + "{THREAD_TITLE_LOG_PREFIX} skipping user-chosen title" ); return Ok(envelope( thread_to_summary(thread), @@ -46,13 +71,7 @@ pub async fn thread_generate_title( )); } - let messages = - conversations::blocking::get_messages(dir.clone(), request.thread_id.clone()).await?; - let Some(first_user_message) = messages - .iter() - .find(|message| message.sender == "user" && !message.content.trim().is_empty()) - .map(|message| message.content.trim().to_string()) - else { + let Some(first_user_message) = first_user_message else { tracing::debug!( thread_id = %request.thread_id, "{THREAD_TITLE_LOG_PREFIX} no user message yet; skipping" From d9f576d8925d6db813ed52e18c91e1457c52d203 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:14:33 +0300 Subject: [PATCH 005/126] feat(threads): generate thread titles from first message Thread titles are now derived from the opening message so new threads get a meaningful name without manual input. Tests cover the generation path. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../src/threads/ops/title_generation.rs | 30 ++++++++++++++----- .../src/threads/ops/title_generation_tests.rs | 29 ++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 crates/openhuman-core/src/threads/ops/title_generation_tests.rs diff --git a/crates/openhuman-core/src/threads/ops/title_generation.rs b/crates/openhuman-core/src/threads/ops/title_generation.rs index c9c19253258..6d26b465c1f 100644 --- a/crates/openhuman-core/src/threads/ops/title_generation.rs +++ b/crates/openhuman-core/src/threads/ops/title_generation.rs @@ -23,15 +23,23 @@ use tinyagents_harness::title::{ /// is written the moment the user sends — before any reply exists to summarize /// — so without this the real summary that runs on the finished reply found a /// non-placeholder title and never ran. Anything else was typed by the user -/// and is left alone. A user who renames a thread to exactly the interim title -/// gets it summarized once more, which is harmless. -pub(crate) fn is_replaceable_title(title: &str, first_user_message: Option<&str>) -> bool { +/// and is left alone. +/// +/// The interim title is only replaceable during the first exchange +/// (`agent_replies <= 1`). A summary that happens to equal the interim title +/// would otherwise look interim forever and be re-summarized on every turn. +pub(crate) fn is_replaceable_title( + title: &str, + first_user_message: Option<&str>, + agent_replies: usize, +) -> bool { if is_auto_generated_thread_title(title) { return true; } - first_user_message - .and_then(title_from_user_message) - .is_some_and(|interim| interim == title.trim()) + agent_replies <= 1 + && first_user_message + .and_then(title_from_user_message) + .is_some_and(|interim| interim == title.trim()) } /// Generates a durable thread title from the first user message and assistant reply. @@ -57,7 +65,11 @@ pub async fn thread_generate_title( .find(|message| message.sender == "user" && !message.content.trim().is_empty()) .map(|message| message.content.trim().to_string()); - if !is_replaceable_title(&thread.title, first_user_message.as_deref()) { + let agent_replies = messages + .iter() + .filter(|message| message.sender == "agent") + .count(); + if !is_replaceable_title(&thread.title, first_user_message.as_deref(), agent_replies) { tracing::debug!( thread_id = %request.thread_id, title_len = thread.title.chars().count(), @@ -208,3 +220,7 @@ pub async fn thread_generate_title( None, )) } + +#[cfg(test)] +#[path = "title_generation_tests.rs"] +mod tests; diff --git a/crates/openhuman-core/src/threads/ops/title_generation_tests.rs b/crates/openhuman-core/src/threads/ops/title_generation_tests.rs new file mode 100644 index 00000000000..cced55e131d --- /dev/null +++ b/crates/openhuman-core/src/threads/ops/title_generation_tests.rs @@ -0,0 +1,29 @@ +use super::*; + +#[test] +fn placeholder_title_is_always_replaceable() { + assert!(is_replaceable_title("Chat Oct 6 3:04 PM", None, 0)); + assert!(is_replaceable_title("Chat Oct 6 3:04 PM", Some("hi"), 5)); +} + +#[test] +fn interim_title_from_first_message_is_replaceable_during_first_exchange() { + let message = "Can you retrieve my latest 5 emails and summarize them?"; + let interim = title_from_user_message(message).expect("interim title"); + assert!(is_replaceable_title(&interim, Some(message), 0)); + assert!(is_replaceable_title(&interim, Some(message), 1)); +} + +#[test] +fn interim_title_is_kept_after_the_first_exchange() { + let message = "Can you retrieve my latest 5 emails and summarize them?"; + let interim = title_from_user_message(message).expect("interim title"); + assert!(!is_replaceable_title(&interim, Some(message), 2)); +} + +#[test] +fn user_chosen_title_is_never_replaced() { + let message = "Can you retrieve my latest 5 emails and summarize them?"; + assert!(!is_replaceable_title("Inbox triage", Some(message), 0)); + assert!(!is_replaceable_title("Inbox triage", None, 0)); +} From 7c00bb1426fb9b237682e96ec5b588d32a749ddb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:15:00 +0300 Subject: [PATCH 006/126] feat(conversations): add thread grouping helper with tests Introduce a groupThreads utility that organizes conversation threads into groups, along with a test suite covering its behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../threadList/groupThreads.test.ts | 103 ++++++++++++++++++ .../conversations/threadList/groupThreads.ts | 97 +++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 app/src/features/conversations/threadList/groupThreads.test.ts create mode 100644 app/src/features/conversations/threadList/groupThreads.ts diff --git a/app/src/features/conversations/threadList/groupThreads.test.ts b/app/src/features/conversations/threadList/groupThreads.test.ts new file mode 100644 index 00000000000..bad25018932 --- /dev/null +++ b/app/src/features/conversations/threadList/groupThreads.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; + +import type { Thread } from '../../../types/thread'; +import { + folderBasename, + groupThreads, + isThreadPinned, + labelsWithPin, + PINNED_THREAD_LABEL, + recencyGroupFor, + threadMatchesQuery, +} from './groupThreads'; + +const NOW = new Date(2026, 9, 6, 15, 0, 0); + +function thread(id: string, lastMessageAt: Date, labels: string[] = []): Thread { + return { + id, + title: id, + chatId: null, + isActive: true, + messageCount: 1, + lastMessageAt: lastMessageAt.toISOString(), + createdAt: lastMessageAt.toISOString(), + labels, + }; +} + +const daysAgo = (days: number, hour = 12) => + new Date(NOW.getFullYear(), NOW.getMonth(), NOW.getDate() - days, hour); + +describe('recencyGroupFor', () => { + it('buckets by local calendar day', () => { + expect(recencyGroupFor(daysAgo(0, 0).toISOString(), NOW)).toBe('today'); + expect(recencyGroupFor(daysAgo(1, 23).toISOString(), NOW)).toBe('yesterday'); + expect(recencyGroupFor(daysAgo(1, 0).toISOString(), NOW)).toBe('yesterday'); + expect(recencyGroupFor(daysAgo(5).toISOString(), NOW)).toBe('previous7Days'); + expect(recencyGroupFor(daysAgo(20).toISOString(), NOW)).toBe('previous30Days'); + expect(recencyGroupFor(daysAgo(90).toISOString(), NOW)).toBe('older'); + }); + + it('sends unparseable timestamps to older', () => { + expect(recencyGroupFor('not a date', NOW)).toBe('older'); + }); +}); + +describe('groupThreads', () => { + it('puts pinned threads first and keeps order within sections', () => { + const groups = groupThreads( + [ + thread('a', daysAgo(0)), + thread('p', daysAgo(40), [PINNED_THREAD_LABEL]), + thread('b', daysAgo(0, 9)), + thread('c', daysAgo(3)), + ], + NOW + ); + expect(groups.map(g => [g.key, g.threads.map(t => t.id)])).toEqual([ + ['pinned', ['p']], + ['today', ['a', 'b']], + ['previous7Days', ['c']], + ]); + }); + + it('honours a custom pin predicate', () => { + const groups = groupThreads([thread('a', daysAgo(0))], NOW, () => true); + expect(groups).toEqual([{ key: 'pinned', threads: [expect.objectContaining({ id: 'a' })] }]); + }); + + it('returns no sections for no threads', () => { + expect(groupThreads([], NOW)).toEqual([]); + }); +}); + +describe('pin labels', () => { + it('toggles only the reserved label', () => { + expect(labelsWithPin(['general'], true)).toEqual(['general', PINNED_THREAD_LABEL]); + expect(labelsWithPin(['general', PINNED_THREAD_LABEL], true)).toEqual([ + 'general', + PINNED_THREAD_LABEL, + ]); + expect(labelsWithPin([PINNED_THREAD_LABEL, 'general'], false)).toEqual(['general']); + expect(labelsWithPin(undefined, false)).toEqual([]); + expect(isThreadPinned(thread('x', NOW, [PINNED_THREAD_LABEL]))).toBe(true); + expect(isThreadPinned(thread('y', NOW))).toBe(false); + }); +}); + +describe('threadMatchesQuery', () => { + it('matches case-insensitively and treats blank as all', () => { + expect(threadMatchesQuery('Fix Gmail OAuth', 'gmail')).toBe(true); + expect(threadMatchesQuery('Fix Gmail OAuth', ' ')).toBe(true); + expect(threadMatchesQuery('Fix Gmail OAuth', 'slack')).toBe(false); + }); +}); + +describe('folderBasename', () => { + it('returns the last segment on either separator', () => { + expect(folderBasename('/home/me/projects/site/')).toBe('site'); + expect(folderBasename('C:\\Users\\me\\repo')).toBe('repo'); + expect(folderBasename('/')).toBe('/'); + }); +}); diff --git a/app/src/features/conversations/threadList/groupThreads.ts b/app/src/features/conversations/threadList/groupThreads.ts new file mode 100644 index 00000000000..d4f64bc6dbf --- /dev/null +++ b/app/src/features/conversations/threadList/groupThreads.ts @@ -0,0 +1,97 @@ +import type { Thread } from '../../../types/thread'; + +/** + * Reserved thread label that marks a conversation as pinned. Persisted by the + * core like any other label (`openhuman.threads_update_labels`), so a pin + * survives restarts and follows the workspace. It never makes a thread a task + * (`isThreadVisibleInTab` keys tasks off other labels), so pinned threads stay + * in the General tab. + */ +export const PINNED_THREAD_LABEL = 'pinned'; + +export type ThreadGroupKey = + | 'pinned' + | 'today' + | 'yesterday' + | 'previous7Days' + | 'previous30Days' + | 'older'; + +export interface ThreadGroup { + key: ThreadGroupKey; + threads: Thread[]; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +export function isThreadPinned(thread: Thread): boolean { + return Boolean(thread.labels?.includes(PINNED_THREAD_LABEL)); +} + +/** Labels with the pin toggled; every other label is kept in order. */ +export function labelsWithPin(labels: readonly string[] | undefined, pinned: boolean): string[] { + const rest = (labels ?? []).filter(label => label !== PINNED_THREAD_LABEL); + return pinned ? [...rest, PINNED_THREAD_LABEL] : rest; +} + +/** Case-insensitive title match; a blank query matches everything. */ +export function threadMatchesQuery(title: string, query: string): boolean { + const needle = query.trim().toLowerCase(); + return needle === '' || title.toLowerCase().includes(needle); +} + +function startOfLocalDay(date: Date): number { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); +} + +/** Which recency bucket a timestamp falls into, relative to `now`'s local day. */ +export function recencyGroupFor(timestamp: string, now: Date): Exclude { + const time = new Date(timestamp).getTime(); + // An unparseable timestamp sorts to the bottom rather than claiming "Today". + if (Number.isNaN(time)) return 'older'; + const today = startOfLocalDay(now); + if (time >= today) return 'today'; + if (time >= today - DAY_MS) return 'yesterday'; + if (time >= today - 7 * DAY_MS) return 'previous7Days'; + if (time >= today - 30 * DAY_MS) return 'previous30Days'; + return 'older'; +} + +const GROUP_ORDER: ThreadGroupKey[] = [ + 'pinned', + 'today', + 'yesterday', + 'previous7Days', + 'previous30Days', + 'older', +]; + +/** + * Splits an already-sorted thread list into the sidebar's sections: pinned + * first, then recency buckets by `lastMessageAt`. Input order is preserved + * inside each section and empty sections are dropped. + */ +export function groupThreads( + threads: readonly Thread[], + now: Date, + isPinned: (thread: Thread) => boolean = isThreadPinned +): ThreadGroup[] { + const buckets = new Map(); + for (const thread of threads) { + const key = isPinned(thread) ? 'pinned' : recencyGroupFor(thread.lastMessageAt, now); + const bucket = buckets.get(key); + if (bucket) bucket.push(thread); + else buckets.set(key, [thread]); + } + return GROUP_ORDER.filter(key => buckets.has(key)).map(key => ({ + key, + threads: buckets.get(key) ?? [], + })); +} + +/** Last path segment of a folder, for a compact tooltip. */ +export function folderBasename(path: string): string { + const trimmed = path.replace(/[\\/]+$/, ''); + const parts = trimmed.split(/[\\/]/); + return parts[parts.length - 1] || trimmed || path; +} From 7f5562db6d175f4555c909c009ecc5f7a8eedf3c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:15:15 +0300 Subject: [PATCH 007/126] feat(config): add local AI inference settings Add configuration schema and runtime plumbing for local AI inference providers, including model selection and snapshot loading. Also add a hook for tracking unread conversation threads. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../threadList/useUnreadThreads.test.ts | 42 ++++++++++++++++ .../threadList/useUnreadThreads.ts | 49 +++++++++++++++++++ .../src/agent/tinyagents/reasoning.rs | 19 ++++--- .../src/agent/tinyagents/reasoning_tests.rs | 21 ++++++++ .../src/config/ops/loader/snapshot.rs | 2 + crates/openhuman-core/src/config/ops/model.rs | 29 +++++++++-- .../config/ops_model_and_local_ai_tests.rs | 36 ++++++++++++++ .../src/config/schema/runtime.rs | 9 +++- .../config/schemas/controllers/inference.rs | 1 + .../src/config/schemas/helpers.rs | 1 + .../config/schemas/schema_defs/inference.rs | 4 ++ 11 files changed, 203 insertions(+), 10 deletions(-) create mode 100644 app/src/features/conversations/threadList/useUnreadThreads.test.ts create mode 100644 app/src/features/conversations/threadList/useUnreadThreads.ts diff --git a/app/src/features/conversations/threadList/useUnreadThreads.test.ts b/app/src/features/conversations/threadList/useUnreadThreads.test.ts new file mode 100644 index 00000000000..f379b5120be --- /dev/null +++ b/app/src/features/conversations/threadList/useUnreadThreads.test.ts @@ -0,0 +1,42 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { useUnreadThreads } from './useUnreadThreads'; + +describe('useUnreadThreads', () => { + it('marks a background thread unread when its run finishes', () => { + const { result, rerender } = renderHook( + ({ running, selected }: { running: string[]; selected: string | null }) => + useUnreadThreads(running, selected), + { initialProps: { running: ['a', 'b'], selected: 'a' } } + ); + expect(result.current.size).toBe(0); + + rerender({ running: [], selected: 'a' }); + // 'a' was selected when it finished, so only 'b' is unread. + expect([...result.current]).toEqual(['b']); + }); + + it('clears unread when the thread is selected', () => { + const { result, rerender } = renderHook( + ({ running, selected }: { running: string[]; selected: string | null }) => + useUnreadThreads(running, selected), + { initialProps: { running: ['b'], selected: 'a' as string | null } } + ); + rerender({ running: [], selected: 'a' }); + expect(result.current.has('b')).toBe(true); + + rerender({ running: [], selected: 'b' }); + expect(result.current.has('b')).toBe(false); + }); + + it('does not mark threads that never ran', () => { + const { result, rerender } = renderHook( + ({ running, selected }: { running: string[]; selected: string | null }) => + useUnreadThreads(running, selected), + { initialProps: { running: [] as string[], selected: null as string | null } } + ); + rerender({ running: [], selected: 'x' }); + expect(result.current.size).toBe(0); + }); +}); diff --git a/app/src/features/conversations/threadList/useUnreadThreads.ts b/app/src/features/conversations/threadList/useUnreadThreads.ts new file mode 100644 index 00000000000..876f73a6b0e --- /dev/null +++ b/app/src/features/conversations/threadList/useUnreadThreads.ts @@ -0,0 +1,49 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Tracks which threads finished a reply while the user was looking elsewhere. + * + * A thread becomes unread on the running → idle edge when it is not the + * selected thread, and is cleared the moment it is selected. Runtime-only: + * the core has no read receipts, so this resets on reload rather than + * inventing a persisted "seen" state the backend cannot confirm. + * + * `runningThreadIds` must be a stable-per-content key (sorted ids joined) so + * the effect only runs when the running set actually changes. + */ +export function useUnreadThreads( + runningThreadIds: readonly string[], + selectedThreadId: string | null +): ReadonlySet { + const [unread, setUnread] = useState>(() => new Set()); + const previousRunning = useRef>(new Set()); + const runningKey = runningThreadIds.join('\u0000'); + + useEffect(() => { + const running = new Set(runningThreadIds); + const finished = [...previousRunning.current].filter( + id => !running.has(id) && id !== selectedThreadId + ); + previousRunning.current = running; + if (finished.length === 0) return; + setUnread(prev => { + const next = new Set(prev); + for (const id of finished) next.add(id); + return next; + }); + // `runningKey` carries the content of `runningThreadIds`. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [runningKey, selectedThreadId]); + + useEffect(() => { + if (!selectedThreadId) return; + setUnread(prev => { + if (!prev.has(selectedThreadId)) return prev; + const next = new Set(prev); + next.delete(selectedThreadId); + return next; + }); + }, [selectedThreadId]); + + return unread; +} diff --git a/crates/openhuman-core/src/agent/tinyagents/reasoning.rs b/crates/openhuman-core/src/agent/tinyagents/reasoning.rs index b4f1a471f67..95cee0a6a18 100644 --- a/crates/openhuman-core/src/agent/tinyagents/reasoning.rs +++ b/crates/openhuman-core/src/agent/tinyagents/reasoning.rs @@ -7,8 +7,9 @@ //! 1. the thread's own choice, recorded by `channel_web_chat`'s //! `reasoning_effort` param ([`apply_requested_effort`]) — read at turn //! start, so changing it never evicts the thread's warm session; -//! 2. `runtime.reasoning_effort` in the session's effective config; -//! 3. `runtime.reasoning_enabled = false`, meaning "no reasoning". +//! 2. the turn model's own level, `runtime.reasoning_effort_by_model`; +//! 3. `runtime.reasoning_effort` in the session's effective config; +//! 4. `runtime.reasoning_enabled = false`, meaning "no reasoning". //! //! Nothing set means the provider keeps its own default. @@ -176,12 +177,18 @@ pub(crate) fn parse_reasoning_effort(raw: &str) -> Option { /// The reasoning config an agent turn on `config` should request, or `None` /// to leave the provider default alone. +/// +/// The turn's model's own level (`runtime.reasoning_effort_by_model`, keyed by +/// `default_model`, which a chat's `model_override` has already replaced in +/// the session config) outranks the global `runtime.reasoning_effort`. pub(crate) fn reasoning_for_config(config: &Config) -> Option { - if let Some(raw) = config - .runtime - .reasoning_effort + let per_model = config + .default_model .as_deref() - .map(str::trim) + .and_then(|model| config.runtime.reasoning_effort_by_model.get(model.trim())); + if let Some(raw) = per_model + .or(config.runtime.reasoning_effort.as_ref()) + .map(|raw| raw.trim()) .filter(|raw| !raw.is_empty()) { return match parse_reasoning_effort(raw) { diff --git a/crates/openhuman-core/src/agent/tinyagents/reasoning_tests.rs b/crates/openhuman-core/src/agent/tinyagents/reasoning_tests.rs index f1ae4cc7369..f45d6d9a143 100644 --- a/crates/openhuman-core/src/agent/tinyagents/reasoning_tests.rs +++ b/crates/openhuman-core/src/agent/tinyagents/reasoning_tests.rs @@ -28,6 +28,27 @@ fn configured_effort_becomes_the_turn_reasoning() { ); } +#[test] +fn the_turn_models_own_level_wins_over_the_global_one() { + let mut config = Config::default(); + config.runtime.reasoning_effort = Some("low".into()); + config + .runtime + .reasoning_effort_by_model + .insert("deep-model".into(), "high".into()); + config.default_model = Some("deep-model".into()); + assert_eq!( + reasoning_for_config(&config), + Some(ReasoningConfig::effort(ReasoningEffort::High)) + ); + // Another model has no entry and falls back to the global level. + config.default_model = Some("quick-model".into()); + assert_eq!( + reasoning_for_config(&config), + Some(ReasoningConfig::effort(ReasoningEffort::Low)) + ); +} + #[test] fn reasoning_disabled_without_an_effort_asks_for_none() { let mut config = Config::default(); diff --git a/crates/openhuman-core/src/config/ops/loader/snapshot.rs b/crates/openhuman-core/src/config/ops/loader/snapshot.rs index 375a72c25f3..b128191f98c 100644 --- a/crates/openhuman-core/src/config/ops/loader/snapshot.rs +++ b/crates/openhuman-core/src/config/ops/loader/snapshot.rs @@ -77,6 +77,8 @@ pub fn client_config_json(config: &Config) -> serde_json::Value { // The composer's thinking-level default (`runtime.reasoning_effort`); // `null` means the provider decides. "reasoning_effort": config.runtime.reasoning_effort, + // Per-model thinking levels (`runtime.reasoning_effort_by_model`). + "reasoning_effort_by_model": config.runtime.reasoning_effort_by_model, "app_version": app_version, "api_key_set": api_key_set, "model_routes": model_routes, diff --git a/crates/openhuman-core/src/config/ops/model.rs b/crates/openhuman-core/src/config/ops/model.rs index 980a8d2845a..6b0358d9343 100644 --- a/crates/openhuman-core/src/config/ops/model.rs +++ b/crates/openhuman-core/src/config/ops/model.rs @@ -54,6 +54,10 @@ pub struct RuntimeSettingsPatch { pub reasoning_enabled: Option, /// `Some("")` clears the effort back to the provider default. pub reasoning_effort: Option, + /// When set, `reasoning_effort` is saved as this model's own level + /// (`runtime.reasoning_effort_by_model`) instead of the global one, and + /// `Some("")` removes the model's entry. + pub reasoning_effort_model: Option, } #[derive(Debug, Clone, Default)] @@ -462,12 +466,31 @@ pub async fn apply_runtime_settings( } if let Some(effort) = update.reasoning_effort { let effort = effort.trim(); - if effort.is_empty() { - config.runtime.reasoning_effort = None; + let parsed = if effort.is_empty() { + None } else { let parsed = crate::agent::tinyagents::parse_reasoning_effort(effort) .ok_or_else(|| format!("unknown reasoning_effort '{effort}'"))?; - config.runtime.reasoning_effort = Some(parsed.as_str().to_string()); + Some(parsed.as_str().to_string()) + }; + let model = update + .reasoning_effort_model + .as_deref() + .map(str::trim) + .filter(|model| !model.is_empty()); + match (model, parsed) { + (Some(model), Some(effort)) => { + log::debug!("[config][reasoning] model={model} effort={effort}"); + config + .runtime + .reasoning_effort_by_model + .insert(model.to_string(), effort); + } + (Some(model), None) => { + log::debug!("[config][reasoning] model={model} effort cleared"); + config.runtime.reasoning_effort_by_model.remove(model); + } + (None, effort) => config.runtime.reasoning_effort = effort, } } config.save().await.map_err(|e| e.to_string())?; diff --git a/crates/openhuman-core/src/config/ops_model_and_local_ai_tests.rs b/crates/openhuman-core/src/config/ops_model_and_local_ai_tests.rs index 3267cc5cb19..bb4792197c8 100644 --- a/crates/openhuman-core/src/config/ops_model_and_local_ai_tests.rs +++ b/crates/openhuman-core/src/config/ops_model_and_local_ai_tests.rs @@ -365,6 +365,7 @@ async fn apply_runtime_settings_updates_kind_and_reasoning() { kind: Some("desktop".into()), reasoning_enabled: Some(true), reasoning_effort: Some("max".into()), + ..RuntimeSettingsPatch::default() }; let _ = apply_runtime_settings(&mut cfg, patch) .await @@ -390,6 +391,41 @@ async fn apply_runtime_settings_updates_kind_and_reasoning() { assert!(apply_runtime_settings(&mut cfg, bogus).await.is_err()); } +#[tokio::test] +async fn apply_runtime_settings_keeps_a_level_per_model() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + cfg.runtime.reasoning_effort = Some("low".into()); + let per_model = RuntimeSettingsPatch { + reasoning_effort: Some("max".into()), + reasoning_effort_model: Some("anthropic/claude-opus".into()), + ..RuntimeSettingsPatch::default() + }; + let _ = apply_runtime_settings(&mut cfg, per_model) + .await + .expect("apply per-model"); + assert_eq!( + cfg.runtime + .reasoning_effort_by_model + .get("anthropic/claude-opus") + .map(String::as_str), + Some("xhigh") + ); + // The global level is untouched by a per-model write. + assert_eq!(cfg.runtime.reasoning_effort.as_deref(), Some("low")); + + let cleared = RuntimeSettingsPatch { + reasoning_effort: Some(String::new()), + reasoning_effort_model: Some("anthropic/claude-opus".into()), + ..RuntimeSettingsPatch::default() + }; + let _ = apply_runtime_settings(&mut cfg, cleared) + .await + .expect("clear per-model"); + assert!(cfg.runtime.reasoning_effort_by_model.is_empty()); + assert_eq!(cfg.runtime.reasoning_effort.as_deref(), Some("low")); +} + #[tokio::test] async fn apply_browser_settings_updates_enabled_flag() { let tmp = tempdir().unwrap(); diff --git a/crates/openhuman-core/src/config/schema/runtime.rs b/crates/openhuman-core/src/config/schema/runtime.rs index 7180e70faf0..36aed950b39 100644 --- a/crates/openhuman-core/src/config/schema/runtime.rs +++ b/crates/openhuman-core/src/config/schema/runtime.rs @@ -3,7 +3,7 @@ use super::defaults; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(default)] @@ -24,6 +24,12 @@ pub struct RuntimeConfig { /// `reasoning_enabled = false`, turns ask for no reasoning. #[serde(default, skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, + /// Per-model thinking level, keyed by the model id the turn runs on + /// (`default_model` after a chat's `model_override`). Same values as + /// [`Self::reasoning_effort`], which it outranks for that model; a model + /// with no entry falls back to the global level. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub reasoning_effort_by_model: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -104,6 +110,7 @@ impl Default for RuntimeConfig { local_jail: super::LocalJailConfig::default(), reasoning_enabled: None, reasoning_effort: None, + reasoning_effort_by_model: BTreeMap::new(), } } } diff --git a/crates/openhuman-core/src/config/schemas/controllers/inference.rs b/crates/openhuman-core/src/config/schemas/controllers/inference.rs index b90f6d85490..537afa6148f 100644 --- a/crates/openhuman-core/src/config/schemas/controllers/inference.rs +++ b/crates/openhuman-core/src/config/schemas/controllers/inference.rs @@ -165,6 +165,7 @@ pub(super) fn handle_update_runtime_settings(params: Map) -> Cont kind: update.kind, reasoning_enabled: update.reasoning_enabled, reasoning_effort: update.reasoning_effort, + reasoning_effort_model: update.reasoning_effort_model, }; to_json(config_rpc::load_and_apply_runtime_settings(patch).await?) }) diff --git a/crates/openhuman-core/src/config/schemas/helpers.rs b/crates/openhuman-core/src/config/schemas/helpers.rs index 97230629c6e..ce5dcd3e87f 100644 --- a/crates/openhuman-core/src/config/schemas/helpers.rs +++ b/crates/openhuman-core/src/config/schemas/helpers.rs @@ -83,6 +83,7 @@ pub(super) struct RuntimeSettingsUpdate { pub(super) kind: Option, pub(super) reasoning_enabled: Option, pub(super) reasoning_effort: Option, + pub(super) reasoning_effort_model: Option, } #[derive(Debug, Deserialize)] diff --git a/crates/openhuman-core/src/config/schemas/schema_defs/inference.rs b/crates/openhuman-core/src/config/schemas/schema_defs/inference.rs index fd08dcf3167..6be05b930f2 100644 --- a/crates/openhuman-core/src/config/schemas/schema_defs/inference.rs +++ b/crates/openhuman-core/src/config/schemas/schema_defs/inference.rs @@ -127,6 +127,10 @@ pub(super) fn lookup(function: &str) -> Option { "reasoning_effort", "Default reasoning effort for agent turns: none, minimal, low, medium, high or xhigh. Empty string clears it back to the provider default.", ), + optional_string( + "reasoning_effort_model", + "When set, reasoning_effort is saved as this model id's own thinking level instead of the global default; an empty reasoning_effort removes the model's entry.", + ), ], outputs: vec![json_output("snapshot", "Updated config snapshot.")], }), From 17d210eabf9af21da1813270439b0f0cba5ecea7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:15:25 +0300 Subject: [PATCH 008/126] feat(conversations): track unread threads in thread list Added a hook that derives the set of unread threads for the conversation list so the UI can highlight them. The logic lives in its own module to keep the list component focused on rendering. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/threadList/useUnreadThreads.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/features/conversations/threadList/useUnreadThreads.ts b/app/src/features/conversations/threadList/useUnreadThreads.ts index 876f73a6b0e..a50acae37e2 100644 --- a/app/src/features/conversations/threadList/useUnreadThreads.ts +++ b/app/src/features/conversations/threadList/useUnreadThreads.ts @@ -8,19 +8,21 @@ import { useEffect, useRef, useState } from 'react'; * the core has no read receipts, so this resets on reload rather than * inventing a persisted "seen" state the backend cannot confirm. * - * `runningThreadIds` must be a stable-per-content key (sorted ids joined) so - * the effect only runs when the running set actually changes. + * The ids are folded into a string key, so a fresh array with the same + * content each render does not re-run the edge detection. */ +const SEPARATOR = '\u0000'; + export function useUnreadThreads( runningThreadIds: readonly string[], selectedThreadId: string | null ): ReadonlySet { const [unread, setUnread] = useState>(() => new Set()); const previousRunning = useRef>(new Set()); - const runningKey = runningThreadIds.join('\u0000'); + const runningKey = runningThreadIds.join(SEPARATOR); useEffect(() => { - const running = new Set(runningThreadIds); + const running = new Set(runningKey ? runningKey.split(SEPARATOR) : []); const finished = [...previousRunning.current].filter( id => !running.has(id) && id !== selectedThreadId ); @@ -31,8 +33,6 @@ export function useUnreadThreads( for (const id of finished) next.add(id); return next; }); - // `runningKey` carries the content of `runningThreadIds`. - // eslint-disable-next-line react-hooks/exhaustive-deps }, [runningKey, selectedThreadId]); useEffect(() => { From e2020767a9233a7d0e5486b4581a60aba64ec0a0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:15:40 +0300 Subject: [PATCH 009/126] feat(conversations): add thread list component Introduce a ThreadList component for the conversations feature to render the list of threads. This provides the base UI for browsing conversation threads. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/threadList/ThreadList.tsx | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/app/src/features/conversations/threadList/ThreadList.tsx b/app/src/features/conversations/threadList/ThreadList.tsx index 9c0b2644d5d..61be7816b44 100644 --- a/app/src/features/conversations/threadList/ThreadList.tsx +++ b/app/src/features/conversations/threadList/ThreadList.tsx @@ -1,8 +1,25 @@ -import type { RefObject } from 'react'; +import { type RefObject, useMemo, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import type { Thread } from '../../../types/thread'; import { isImeCompositionKeyEvent } from '../Conversations'; +import { + folderBasename, + groupThreads, + isThreadPinned, + type ThreadGroupKey, + threadMatchesQuery, +} from './groupThreads'; + +/** i18n key for each section header. */ +const GROUP_LABEL_KEYS: Record = { + pinned: 'chat.sidebar.group.pinned', + today: 'chat.sidebar.group.today', + yesterday: 'chat.sidebar.group.yesterday', + previous7Days: 'chat.sidebar.group.previous7Days', + previous30Days: 'chat.sidebar.group.previous30Days', + older: 'chat.sidebar.group.older', +}; interface ThreadListProps { /** Threads visible after the sidebar's search/tab filtering. */ @@ -15,6 +32,15 @@ interface ThreadListProps { resolveTitle: (threadId: string) => string; /** Whether a thread has an agent turn in flight; its title shimmers while true. */ isThreadRunning?: (threadId: string) => boolean; + /** Threads whose reply finished while another thread was selected. */ + unreadThreadIds?: ReadonlySet; + /** + * Pin state per thread. Defaults to the persisted `pinned` label; the parent + * passes its own when it applies a pin optimistically ahead of the reload. + */ + isPinned?: (thread: Thread) => boolean; + /** Pin or unpin a thread. The pin action is hidden when omitted. */ + onTogglePin?: (thread: Thread, pinned: boolean) => void; onRequestDelete: (thread: Thread) => void; // Inline title rename — controlled by the parent so the edit state stays // co-located with the rest of the panel's thread state. @@ -41,6 +67,9 @@ export function ThreadList({ onSelectThread, resolveTitle, isThreadRunning, + unreadThreadIds, + isPinned = isThreadPinned, + onTogglePin, onRequestDelete, editingThreadId, editTitleValue, @@ -52,6 +81,14 @@ export function ThreadList({ onBlurTitle, }: ThreadListProps) { const { t } = useT(); + const [query, setQuery] = useState(''); + const visibleThreads = useMemo( + () => threads.filter(thread => threadMatchesQuery(resolveTitle(thread.id), query)), + [threads, query, resolveTitle] + ); + // Recomputed per render on purpose: a list left open overnight should move + // yesterday's rows out of "Today" on the next update without a timer. + const groups = groupThreads(visibleThreads, new Date(), isPinned); return ( // Card background / rounded corners come from TwoPanelLayout's pane styling.
From 64bcb64ec6c24d3f47f382210d7d4bd530b809ee Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:16:11 +0300 Subject: [PATCH 010/126] feat(conversations): add thread list component Introduce a ThreadList component for the conversations feature to render the list of threads. This provides the base UI needed to display conversation threads in the sidebar. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/threadList/ThreadList.tsx | 251 ++++++++++-------- 1 file changed, 142 insertions(+), 109 deletions(-) diff --git a/app/src/features/conversations/threadList/ThreadList.tsx b/app/src/features/conversations/threadList/ThreadList.tsx index 61be7816b44..79ac8101c28 100644 --- a/app/src/features/conversations/threadList/ThreadList.tsx +++ b/app/src/features/conversations/threadList/ThreadList.tsx @@ -89,6 +89,128 @@ export function ThreadList({ // Recomputed per render on purpose: a list left open overnight should move // yesterday's rows out of "Today" on the next update without a timer. const groups = groupThreads(visibleThreads, new Date(), isPinned); + const renderRow = (thread: Thread) => { + const running = Boolean(isThreadRunning?.(thread.id)); + const unread = !running && Boolean(unreadThreadIds?.has(thread.id)); + const pinned = isPinned(thread); + return ( +
onSelectThread(thread.id)} + onKeyDown={e => { + if (e.target !== e.currentTarget) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelectThread(thread.id); + } + }} + // A rounded pill per row, separated by spacing rather than + // hairlines — six dividers in a short list read as a table, not a + // list of destinations. Alpha fills so the row lifts identically + // whether the list is projected into the (translucent) app sidebar + // or rendered inside the opaque chat aside. + // Fixed `h-8` matching SidebarNav's rows: the hover-revealed + // actions are taller than the title's line box, so a padding-sized + // row would grow 4px the moment the pointer entered it and the + // whole list would shift under the cursor. + className={`group flex h-8 w-full flex-none cursor-pointer items-center rounded-md px-3 text-left transition-colors ${ + selectedThreadId === thread.id + ? 'bg-surface/70' + : 'hover:bg-surface/40 dark:hover:bg-surface/60' + }`}> +
+ {editingThreadId === thread.id ? ( + e.stopPropagation()} + onChange={e => onEditTitleValueChange(e.target.value)} + onKeyDown={e => { + e.stopPropagation(); + // Ignore the Enter that confirms an IME composition + // candidate (CJK input) so it doesn't prematurely commit. + if (isImeCompositionKeyEvent(e)) return; + if (e.key === 'Enter') { + e.preventDefault(); + onCommitTitle(thread.id); + } else if (e.key === 'Escape') { + // Escape is an explicit cancel — suppress the commit the + // ensuing blur would otherwise fire. + onCancelEditTitle(); + } + }} + onBlur={() => onBlurTitle(thread.id)} + aria-label={t('chat.editThreadTitle')} + data-testid={`thread-title-input-${thread.id}`} + className="h-5 min-w-0 flex-1 border-b border-primary-400 bg-transparent py-0 text-xs font-medium leading-none text-content-secondary outline-hidden" + autoFocus + /> + ) : ( +

+ {resolveTitle(thread.id)} +

+ )} + + +
+
+ ); + }; return ( // Card background / rounded corners come from TwoPanelLayout's pane styling.
@@ -217,116 +339,27 @@ export function ThreadList({
{threads.length === 0 ? (

{t('chat.noThreads')}

+ ) : groups.length === 0 ? ( +

+ {t('chat.sidebar.noMatches')} +

) : ( - threads.map(thread => ( -
onSelectThread(thread.id)} - onKeyDown={e => { - if (e.target !== e.currentTarget) return; - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onSelectThread(thread.id); - } - }} - // A rounded pill per row, separated by spacing rather than - // hairlines — six dividers in a short list read as a table, not a - // list of destinations. Alpha fills so the row lifts identically - // whether the list is projected into the (translucent) app sidebar - // or rendered inside the opaque chat aside. - // Fixed `h-8` matching SidebarNav's rows: the hover-revealed - // actions are taller than the title's line box, so a padding-sized - // row would grow 4px the moment the pointer entered it and the - // whole list would shift under the cursor. - className={`group flex h-8 w-full flex-none cursor-pointer items-center rounded-md px-3 text-left transition-colors ${ - selectedThreadId === thread.id - ? 'bg-surface/70' - : 'hover:bg-surface/40 dark:hover:bg-surface/60' - }`}> -
- {editingThreadId === thread.id ? ( - e.stopPropagation()} - onChange={e => onEditTitleValueChange(e.target.value)} - onKeyDown={e => { - e.stopPropagation(); - // Ignore the Enter that confirms an IME composition - // candidate (CJK input) so it doesn't prematurely commit. - if (isImeCompositionKeyEvent(e)) return; - if (e.key === 'Enter') { - e.preventDefault(); - onCommitTitle(thread.id); - } else if (e.key === 'Escape') { - // Escape is an explicit cancel — suppress the commit the - // ensuing blur would otherwise fire. - onCancelEditTitle(); - } - }} - onBlur={() => onBlurTitle(thread.id)} - aria-label={t('chat.editThreadTitle')} - data-testid={`thread-title-input-${thread.id}`} - className="h-5 min-w-0 flex-1 border-b border-primary-400 bg-transparent py-0 text-xs font-medium leading-none text-content-secondary outline-hidden" - autoFocus - /> - ) : ( -

- {resolveTitle(thread.id)} -

- )} - - -
-
+ // Each section is a labelled group so screen readers announce + // "Today, list" etc. The header is a plain muted caption, not a + // control: sections are not collapsible, so they should not look it. + groups.map(group => ( +
+

+ {t(GROUP_LABEL_KEYS[group.key])} +

+ {group.threads.map(renderRow)} +
)) )}
From a26662e040087f5ce83f7624bd4f111b69741c53 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:16:34 +0300 Subject: [PATCH 011/126] feat(conversations): add thread list component Introduce a ThreadList component for the conversations feature to render the list of threads. This provides the base UI needed to display conversation threads in the sidebar. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/threadList/ThreadList.tsx | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/app/src/features/conversations/threadList/ThreadList.tsx b/app/src/features/conversations/threadList/ThreadList.tsx index 79ac8101c28..e3431306143 100644 --- a/app/src/features/conversations/threadList/ThreadList.tsx +++ b/app/src/features/conversations/threadList/ThreadList.tsx @@ -239,7 +239,7 @@ export function ThreadList({ `pb-2` makes the 8px gap below the button match the chat separator's 8px lower margin above it, so the action is optically centred between the primary navigation and the first conversation. */} -
+
{/* "New conversation" as a row, not a header icon. It is the same affordance as a thread row — pick a conversation to work in — so it takes the same shape: `h-8` pill, same radius, same hover fill, same @@ -299,6 +299,56 @@ export function ThreadList({ + {/* Title filter. Same pill geometry as the rows so it sits in their + column; a bare input with no border until focus, because at rest it + is the least important control here. Escape clears it — the one + keystroke anyone tries first to get the full list back. Filtering + is title-only: message search is the command palette's job. */} +
+ + setQuery(e.target.value)} + onKeyDown={e => { + if (e.key === 'Escape' && query) { + e.preventDefault(); + e.stopPropagation(); + setQuery(''); + } + }} + placeholder={t('chat.sidebar.searchPlaceholder')} + aria-label={t('chat.sidebar.searchPlaceholder')} + className="h-8 w-full rounded-md border border-transparent bg-surface/40 pl-8 pr-7 text-[13px] text-content placeholder:text-content-faint outline-hidden transition-colors focus:border-content-faint/35 dark:bg-surface/60 [&::-webkit-search-cancel-button]:hidden" + /> + {query && ( + + )} +
{/* Rows carry no padding gutter of their own — a thread pill spans the full width the scroll container gives it, so its hover/selected fill From 2cadbe3f3c9f2a7a1f2f90b9db6d5310e2249516 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:17:14 +0300 Subject: [PATCH 012/126] feat(conversations): add thread pinning and unread indicators Wire the thread list to a new pinning hook and an unread-thread hook so threads can be pinned and show unread state. Running thread ids are derived from the thread list to feed the unread tracking. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../features/conversations/Conversations.tsx | 11 ++++ .../conversations/threadList/useThreadPins.ts | 60 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 app/src/features/conversations/threadList/useThreadPins.ts diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index ca26fe78acb..4afef7de3f4 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -107,6 +107,8 @@ import { } from './aui/ReasoningEffortPicker'; import { useChatSurfaceRegistration } from './hooks/useChatSurfaceRegistration'; import { ThreadList } from './threadList/ThreadList'; +import { useThreadPins } from './threadList/useThreadPins'; +import { useUnreadThreads } from './threadList/useUnreadThreads'; const CHAT_MODEL_HINT = 'hint:chat'; const debug = debugFactory('conversations'); @@ -1562,6 +1564,12 @@ const Conversations = ({ }, [pendingSendingThreadIds, activeThreadIds, inferenceTurnLifecycleByThread] ); + const runningThreadIds = useMemo( + () => threads.filter(thread => isThreadRunning(thread.id)).map(thread => thread.id), + [threads, isThreadRunning] + ); + const unreadThreadIds = useUnreadThreads(runningThreadIds, selectedThreadId ?? null); + const { isPinned: isThreadPinnedNow, togglePin: toggleThreadPin } = useThreadPins(); // Thread list (left pane). Rendered through `TwoPanelLayout` below in page // mode; the embedded `variant="sidebar"` mode shows no thread list at all. @@ -1579,6 +1587,9 @@ const Conversations = ({ }} resolveTitle={resolveThreadDisplayTitle} isThreadRunning={isThreadRunning} + unreadThreadIds={unreadThreadIds} + isPinned={isThreadPinnedNow} + onTogglePin={toggleThreadPin} onRequestDelete={thread => setDeleteModal({ isOpen: true, diff --git a/app/src/features/conversations/threadList/useThreadPins.ts b/app/src/features/conversations/threadList/useThreadPins.ts new file mode 100644 index 00000000000..8e1088abe52 --- /dev/null +++ b/app/src/features/conversations/threadList/useThreadPins.ts @@ -0,0 +1,60 @@ +import debugFactory from 'debug'; +import { useCallback, useState } from 'react'; + +import { threadApi } from '../../../services/api/threadApi'; +import { useAppDispatch } from '../../../store/hooks'; +import { loadThreads } from '../../../store/threadSlice'; +import type { Thread } from '../../../types/thread'; +import { isThreadPinned, labelsWithPin } from './groupThreads'; + +const debug = debugFactory('conversations:pins'); + +/** + * Pin / unpin a thread through the core's label store. + * + * The pin is the reserved `pinned` label, written with + * `openhuman.threads_update_labels` so it persists with the thread. The row + * moves immediately via a local override, held until the thread list reload + * carries the persisted labels; a failed write drops the override, so the row + * snaps back to what the core actually stored. + */ +export function useThreadPins(): { + isPinned: (thread: Thread) => boolean; + togglePin: (thread: Thread, pinned: boolean) => void; +} { + const dispatch = useAppDispatch(); + const [overrides, setOverrides] = useState>(() => new Map()); + + const isPinned = useCallback( + (thread: Thread) => overrides.get(thread.id) ?? isThreadPinned(thread), + [overrides] + ); + + const togglePin = useCallback( + (thread: Thread, pinned: boolean) => { + setOverrides(prev => new Map(prev).set(thread.id, pinned)); + const clearOverride = () => + setOverrides(prev => { + if (!prev.has(thread.id)) return prev; + const next = new Map(prev); + next.delete(thread.id); + return next; + }); + debug('toggle pin thread=%s pinned=%s', thread.id, pinned); + void threadApi + .updateLabels(thread.id, labelsWithPin(thread.labels, pinned)) + .then(() => dispatch(loadThreads()).unwrap()) + .catch((error: unknown) => { + debug( + 'pin update failed thread=%s: %s', + thread.id, + error instanceof Error ? error.message : String(error) + ); + }) + .finally(clearOverride); + }, + [dispatch] + ); + + return { isPinned, togglePin }; +} From 2e94b6d0da5df39173d5e995b4fb6e244c0588cd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:17:23 +0300 Subject: [PATCH 013/126] feat(i18n): add missing translation keys across locales Added the new strings to every supported locale so the UI no longer falls back to English for these labels. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/ar.ts | 13 +++++++++++++ app/src/lib/i18n/bn.ts | 13 +++++++++++++ app/src/lib/i18n/de.ts | 13 +++++++++++++ app/src/lib/i18n/en.ts | 13 +++++++++++++ app/src/lib/i18n/es.ts | 13 +++++++++++++ app/src/lib/i18n/fr.ts | 13 +++++++++++++ app/src/lib/i18n/hi.ts | 13 +++++++++++++ app/src/lib/i18n/id.ts | 13 +++++++++++++ app/src/lib/i18n/it.ts | 13 +++++++++++++ app/src/lib/i18n/ko.ts | 13 +++++++++++++ app/src/lib/i18n/pl.ts | 13 +++++++++++++ app/src/lib/i18n/pt.ts | 13 +++++++++++++ app/src/lib/i18n/ru.ts | 13 +++++++++++++ app/src/lib/i18n/zh-CN.ts | 13 +++++++++++++ 14 files changed, 182 insertions(+) diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index b0b3ba0300c..7236b36e672 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -472,6 +472,19 @@ const messages: TranslationMap = { 'تعذر التبديل إلى نماذج OpenRouter المجانية. تحقق من تسجيل الدخول إلى OpenRouter وحاول مرة أخرى.', 'chat.newThread': 'محادثة جديدة', 'chat.newConversation': 'محادثة جديدة', + 'chat.sidebar.searchPlaceholder': 'البحث في المحادثات', + 'chat.sidebar.clearSearch': 'مسح البحث', + 'chat.sidebar.noMatches': 'لا توجد محادثات مطابقة', + 'chat.sidebar.group.pinned': 'المثبتة', + 'chat.sidebar.group.today': 'اليوم', + 'chat.sidebar.group.yesterday': 'أمس', + 'chat.sidebar.group.previous7Days': 'آخر 7 أيام', + 'chat.sidebar.group.previous30Days': 'آخر 30 يومًا', + 'chat.sidebar.group.older': 'أقدم', + 'chat.sidebar.pinThread': 'تثبيت المحادثة', + 'chat.sidebar.unpinThread': 'إلغاء تثبيت المحادثة', + 'chat.sidebar.unread': 'رد جديد', + 'chat.sidebar.workingFolder': 'مجلد العمل: {folder}', 'chat.newWindowPrompt': 'كيف يمكنني مساعدتك اليوم؟', 'chat.welcomeSuggestion.calendarToday': 'ما هي مواعيدي اليوم؟', 'chat.welcomeSuggestion.unreadEmail': 'لخّص رسائلي غير المقروءة.', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 54822b79051..cae1be34d46 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -483,6 +483,19 @@ const messages: TranslationMap = { 'OpenRouter-এর বিনামূল্যের মডেলে স্যুইচ করা যায়নি। আপনার OpenRouter সাইন-ইন পরীক্ষা করে আবার চেষ্টা করুন।', 'chat.newThread': 'নতুন থ্রেড', 'chat.newConversation': 'নতুন কথোপকথন', + 'chat.sidebar.searchPlaceholder': 'কথোপকথন খুঁজুন', + 'chat.sidebar.clearSearch': 'অনুসন্ধান মুছুন', + 'chat.sidebar.noMatches': 'কোনো মিল থাকা কথোপকথন নেই', + 'chat.sidebar.group.pinned': 'পিন করা', + 'chat.sidebar.group.today': 'আজ', + 'chat.sidebar.group.yesterday': 'গতকাল', + 'chat.sidebar.group.previous7Days': 'গত ৭ দিন', + 'chat.sidebar.group.previous30Days': 'গত ৩০ দিন', + 'chat.sidebar.group.older': 'পুরোনো', + 'chat.sidebar.pinThread': 'কথোপকথন পিন করুন', + 'chat.sidebar.unpinThread': 'কথোপকথন আনপিন করুন', + 'chat.sidebar.unread': 'নতুন উত্তর', + 'chat.sidebar.workingFolder': 'কাজের ফোল্ডার: {folder}', 'chat.newWindowPrompt': 'আজ আমি আপনাকে কীভাবে সাহায্য করতে পারি?', 'chat.welcomeSuggestion.calendarToday': 'আজ আমার ক্যালেন্ডারে কী আছে?', 'chat.welcomeSuggestion.unreadEmail': 'আমার না পড়া ইমেলগুলোর সারসংক্ষেপ দাও।', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 33c6a4e7932..5fa73aaee70 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -507,6 +507,19 @@ const messages: TranslationMap = { 'Der Wechsel zu kostenlosen OpenRouter-Modellen ist fehlgeschlagen. Prüfe deine OpenRouter-Anmeldung und versuche es erneut.', 'chat.newThread': 'Neuer Thread', 'chat.newConversation': 'Neue Unterhaltung', + 'chat.sidebar.searchPlaceholder': 'Unterhaltungen durchsuchen', + 'chat.sidebar.clearSearch': 'Suche löschen', + 'chat.sidebar.noMatches': 'Keine passenden Unterhaltungen', + 'chat.sidebar.group.pinned': 'Angeheftet', + 'chat.sidebar.group.today': 'Heute', + 'chat.sidebar.group.yesterday': 'Gestern', + 'chat.sidebar.group.previous7Days': 'Letzte 7 Tage', + 'chat.sidebar.group.previous30Days': 'Letzte 30 Tage', + 'chat.sidebar.group.older': 'Älter', + 'chat.sidebar.pinThread': 'Unterhaltung anheften', + 'chat.sidebar.unpinThread': 'Unterhaltung lösen', + 'chat.sidebar.unread': 'Neue Antwort', + 'chat.sidebar.workingFolder': 'Arbeitsordner: {folder}', 'chat.newWindowPrompt': 'Wie kann ich dir heute helfen?', 'chat.welcomeSuggestion.calendarToday': 'Was steht heute in meinem Kalender?', 'chat.welcomeSuggestion.unreadEmail': 'Fasse meine ungelesenen E-Mails zusammen.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index d698c3fd194..32526bf0f84 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -369,6 +369,19 @@ const en: TranslationMap = { // Chat / Conversations 'chat.newThread': 'New thread', 'chat.newConversation': 'New Conversation', + 'chat.sidebar.searchPlaceholder': 'Search conversations', + 'chat.sidebar.clearSearch': 'Clear search', + 'chat.sidebar.noMatches': 'No matching conversations', + 'chat.sidebar.group.pinned': 'Pinned', + 'chat.sidebar.group.today': 'Today', + 'chat.sidebar.group.yesterday': 'Yesterday', + 'chat.sidebar.group.previous7Days': 'Previous 7 days', + 'chat.sidebar.group.previous30Days': 'Previous 30 days', + 'chat.sidebar.group.older': 'Older', + 'chat.sidebar.pinThread': 'Pin conversation', + 'chat.sidebar.unpinThread': 'Unpin conversation', + 'chat.sidebar.unread': 'New reply', + 'chat.sidebar.workingFolder': 'Working folder: {folder}', 'chat.newWindowPrompt': 'How can I help you today?', 'chat.welcomeSuggestion.calendarToday': "What's on my calendar today?", 'chat.welcomeSuggestion.unreadEmail': 'Summarize my unread email.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 379604fc9bc..951ade9dc1e 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -499,6 +499,19 @@ const messages: TranslationMap = { 'No se pudo cambiar a los modelos gratis de OpenRouter. Revisa tu inicio de sesión de OpenRouter e inténtalo de nuevo.', 'chat.newThread': 'Nuevo hilo', 'chat.newConversation': 'Nueva conversación', + 'chat.sidebar.searchPlaceholder': 'Buscar conversaciones', + 'chat.sidebar.clearSearch': 'Borrar búsqueda', + 'chat.sidebar.noMatches': 'No hay conversaciones que coincidan', + 'chat.sidebar.group.pinned': 'Fijadas', + 'chat.sidebar.group.today': 'Hoy', + 'chat.sidebar.group.yesterday': 'Ayer', + 'chat.sidebar.group.previous7Days': 'Últimos 7 días', + 'chat.sidebar.group.previous30Days': 'Últimos 30 días', + 'chat.sidebar.group.older': 'Anteriores', + 'chat.sidebar.pinThread': 'Fijar conversación', + 'chat.sidebar.unpinThread': 'Desfijar conversación', + 'chat.sidebar.unread': 'Respuesta nueva', + 'chat.sidebar.workingFolder': 'Carpeta de trabajo: {folder}', 'chat.newWindowPrompt': '¿En qué puedo ayudarte hoy?', 'chat.welcomeSuggestion.calendarToday': '¿Qué tengo hoy en el calendario?', 'chat.welcomeSuggestion.unreadEmail': 'Resume mis correos sin leer.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index d22601858ea..785afa4de7b 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -503,6 +503,19 @@ const messages: TranslationMap = { 'Impossible de passer aux modèles gratuits OpenRouter. Vérifiez votre connexion OpenRouter et réessayez.', 'chat.newThread': 'Nouveau fil', 'chat.newConversation': 'Nouvelle conversation', + 'chat.sidebar.searchPlaceholder': 'Rechercher des conversations', + 'chat.sidebar.clearSearch': 'Effacer la recherche', + 'chat.sidebar.noMatches': 'Aucune conversation correspondante', + 'chat.sidebar.group.pinned': 'Épinglées', + 'chat.sidebar.group.today': 'Aujourd\'hui', + 'chat.sidebar.group.yesterday': 'Hier', + 'chat.sidebar.group.previous7Days': '7 derniers jours', + 'chat.sidebar.group.previous30Days': '30 derniers jours', + 'chat.sidebar.group.older': 'Plus anciennes', + 'chat.sidebar.pinThread': 'Épingler la conversation', + 'chat.sidebar.unpinThread': 'Désépingler la conversation', + 'chat.sidebar.unread': 'Nouvelle réponse', + 'chat.sidebar.workingFolder': 'Dossier de travail : {folder}', 'chat.newWindowPrompt': "Comment puis-je t'aider aujourd'hui ?", 'chat.welcomeSuggestion.calendarToday': "Qu'est-ce que j'ai à mon agenda aujourd'hui ?", 'chat.welcomeSuggestion.unreadEmail': 'Résume mes e-mails non lus.', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index ee80012e549..1354e517a52 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -487,6 +487,19 @@ const messages: TranslationMap = { 'OpenRouter के मुफ्त मॉडल पर स्विच नहीं हो सका। अपना OpenRouter साइन-इन जांचें और फिर कोशिश करें।', 'chat.newThread': 'नई थ्रेड', 'chat.newConversation': 'नई बातचीत', + 'chat.sidebar.searchPlaceholder': 'बातचीत खोजें', + 'chat.sidebar.clearSearch': 'खोज साफ़ करें', + 'chat.sidebar.noMatches': 'कोई मेल खाती बातचीत नहीं', + 'chat.sidebar.group.pinned': 'पिन की गई', + 'chat.sidebar.group.today': 'आज', + 'chat.sidebar.group.yesterday': 'कल', + 'chat.sidebar.group.previous7Days': 'पिछले 7 दिन', + 'chat.sidebar.group.previous30Days': 'पिछले 30 दिन', + 'chat.sidebar.group.older': 'पुरानी', + 'chat.sidebar.pinThread': 'बातचीत पिन करें', + 'chat.sidebar.unpinThread': 'बातचीत अनपिन करें', + 'chat.sidebar.unread': 'नया जवाब', + 'chat.sidebar.workingFolder': 'कार्य फ़ोल्डर: {folder}', 'chat.newWindowPrompt': 'आज मैं आपकी कैसे मदद कर सकता हूँ?', 'chat.welcomeSuggestion.calendarToday': 'आज मेरे कैलेंडर में क्या है?', 'chat.welcomeSuggestion.unreadEmail': 'मेरे बिना पढ़े ईमेल का सारांश दो।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index ab235c83802..be03e863cce 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -492,6 +492,19 @@ const messages: TranslationMap = { 'Tidak dapat beralih ke model gratis OpenRouter. Periksa login OpenRouter Anda dan coba lagi.', 'chat.newThread': 'Thread baru', 'chat.newConversation': 'Percakapan baru', + 'chat.sidebar.searchPlaceholder': 'Cari percakapan', + 'chat.sidebar.clearSearch': 'Hapus pencarian', + 'chat.sidebar.noMatches': 'Tidak ada percakapan yang cocok', + 'chat.sidebar.group.pinned': 'Disematkan', + 'chat.sidebar.group.today': 'Hari ini', + 'chat.sidebar.group.yesterday': 'Kemarin', + 'chat.sidebar.group.previous7Days': '7 hari terakhir', + 'chat.sidebar.group.previous30Days': '30 hari terakhir', + 'chat.sidebar.group.older': 'Lebih lama', + 'chat.sidebar.pinThread': 'Sematkan percakapan', + 'chat.sidebar.unpinThread': 'Lepas sematan percakapan', + 'chat.sidebar.unread': 'Balasan baru', + 'chat.sidebar.workingFolder': 'Folder kerja: {folder}', 'chat.newWindowPrompt': 'Apa yang bisa saya bantu hari ini?', 'chat.welcomeSuggestion.calendarToday': 'Ada apa saja di kalender saya hari ini?', 'chat.welcomeSuggestion.unreadEmail': 'Ringkas email saya yang belum dibaca.', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index d6fbf94da88..c2811d76dc2 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -498,6 +498,19 @@ const messages: TranslationMap = { 'Impossibile passare ai modelli gratuiti di OpenRouter. Controlla l’accesso a OpenRouter e riprova.', 'chat.newThread': 'Nuovo thread', 'chat.newConversation': 'Nuova conversazione', + 'chat.sidebar.searchPlaceholder': 'Cerca conversazioni', + 'chat.sidebar.clearSearch': 'Cancella ricerca', + 'chat.sidebar.noMatches': 'Nessuna conversazione corrispondente', + 'chat.sidebar.group.pinned': 'Fissate', + 'chat.sidebar.group.today': 'Oggi', + 'chat.sidebar.group.yesterday': 'Ieri', + 'chat.sidebar.group.previous7Days': 'Ultimi 7 giorni', + 'chat.sidebar.group.previous30Days': 'Ultimi 30 giorni', + 'chat.sidebar.group.older': 'Meno recenti', + 'chat.sidebar.pinThread': 'Fissa conversazione', + 'chat.sidebar.unpinThread': 'Sblocca conversazione', + 'chat.sidebar.unread': 'Nuova risposta', + 'chat.sidebar.workingFolder': 'Cartella di lavoro: {folder}', 'chat.newWindowPrompt': 'Come posso aiutarti oggi?', 'chat.welcomeSuggestion.calendarToday': 'Che cosa ho in calendario oggi?', 'chat.welcomeSuggestion.unreadEmail': 'Riassumi le mie email non lette.', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index f82d1560ada..6cabe614595 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -480,6 +480,19 @@ const messages: TranslationMap = { 'OpenRouter 무료 모델로 전환하지 못했습니다. OpenRouter 로그인을 확인하고 다시 시도하세요.', 'chat.newThread': '새 스레드', 'chat.newConversation': '새 대화', + 'chat.sidebar.searchPlaceholder': '대화 검색', + 'chat.sidebar.clearSearch': '검색 지우기', + 'chat.sidebar.noMatches': '일치하는 대화가 없습니다', + 'chat.sidebar.group.pinned': '고정됨', + 'chat.sidebar.group.today': '오늘', + 'chat.sidebar.group.yesterday': '어제', + 'chat.sidebar.group.previous7Days': '지난 7일', + 'chat.sidebar.group.previous30Days': '지난 30일', + 'chat.sidebar.group.older': '이전', + 'chat.sidebar.pinThread': '대화 고정', + 'chat.sidebar.unpinThread': '대화 고정 해제', + 'chat.sidebar.unread': '새 답장', + 'chat.sidebar.workingFolder': '작업 폴더: {folder}', 'chat.newWindowPrompt': '오늘 무엇을 도와드릴까요?', 'chat.welcomeSuggestion.calendarToday': '오늘 내 일정은 뭐야?', 'chat.welcomeSuggestion.unreadEmail': '읽지 않은 이메일을 요약해 줘.', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 3e9ea086c82..49d68efe212 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -495,6 +495,19 @@ const messages: TranslationMap = { 'Nie udało się przełączyć na darmowe modele OpenRouter. Sprawdź logowanie do OpenRouter i spróbuj ponownie.', 'chat.newThread': 'Nowy wątek', 'chat.newConversation': 'Nowa rozmowa', + 'chat.sidebar.searchPlaceholder': 'Szukaj rozmów', + 'chat.sidebar.clearSearch': 'Wyczyść wyszukiwanie', + 'chat.sidebar.noMatches': 'Brak pasujących rozmów', + 'chat.sidebar.group.pinned': 'Przypięte', + 'chat.sidebar.group.today': 'Dzisiaj', + 'chat.sidebar.group.yesterday': 'Wczoraj', + 'chat.sidebar.group.previous7Days': 'Ostatnie 7 dni', + 'chat.sidebar.group.previous30Days': 'Ostatnie 30 dni', + 'chat.sidebar.group.older': 'Starsze', + 'chat.sidebar.pinThread': 'Przypnij rozmowę', + 'chat.sidebar.unpinThread': 'Odepnij rozmowę', + 'chat.sidebar.unread': 'Nowa odpowiedź', + 'chat.sidebar.workingFolder': 'Folder roboczy: {folder}', 'chat.newWindowPrompt': 'Jak mogę ci dziś pomóc?', 'chat.welcomeSuggestion.calendarToday': 'Co mam dzisiaj w kalendarzu?', 'chat.welcomeSuggestion.unreadEmail': 'Podsumuj moje nieprzeczytane e-maile.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index e893584277f..d39a47933ae 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -496,6 +496,19 @@ const messages: TranslationMap = { 'Não foi possível mudar para os modelos grátis da OpenRouter. Verifique seu login da OpenRouter e tente novamente.', 'chat.newThread': 'Nova conversa', 'chat.newConversation': 'Nova conversa', + 'chat.sidebar.searchPlaceholder': 'Pesquisar conversas', + 'chat.sidebar.clearSearch': 'Limpar pesquisa', + 'chat.sidebar.noMatches': 'Nenhuma conversa correspondente', + 'chat.sidebar.group.pinned': 'Fixadas', + 'chat.sidebar.group.today': 'Hoje', + 'chat.sidebar.group.yesterday': 'Ontem', + 'chat.sidebar.group.previous7Days': 'Últimos 7 dias', + 'chat.sidebar.group.previous30Days': 'Últimos 30 dias', + 'chat.sidebar.group.older': 'Mais antigas', + 'chat.sidebar.pinThread': 'Fixar conversa', + 'chat.sidebar.unpinThread': 'Desafixar conversa', + 'chat.sidebar.unread': 'Nova resposta', + 'chat.sidebar.workingFolder': 'Pasta de trabalho: {folder}', 'chat.newWindowPrompt': 'Como posso ajudá-lo hoje?', 'chat.welcomeSuggestion.calendarToday': 'O que tenho hoje na agenda?', 'chat.welcomeSuggestion.unreadEmail': 'Resume os meus e-mails não lidos.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 8899a8065d9..a732860f78b 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -492,6 +492,19 @@ const messages: TranslationMap = { 'Не удалось переключиться на бесплатные модели OpenRouter. Проверьте вход в OpenRouter и повторите попытку.', 'chat.newThread': 'Новый чат', 'chat.newConversation': 'Новый разговор', + 'chat.sidebar.searchPlaceholder': 'Поиск разговоров', + 'chat.sidebar.clearSearch': 'Очистить поиск', + 'chat.sidebar.noMatches': 'Нет подходящих разговоров', + 'chat.sidebar.group.pinned': 'Закреплённые', + 'chat.sidebar.group.today': 'Сегодня', + 'chat.sidebar.group.yesterday': 'Вчера', + 'chat.sidebar.group.previous7Days': 'Последние 7 дней', + 'chat.sidebar.group.previous30Days': 'Последние 30 дней', + 'chat.sidebar.group.older': 'Ранее', + 'chat.sidebar.pinThread': 'Закрепить разговор', + 'chat.sidebar.unpinThread': 'Открепить разговор', + 'chat.sidebar.unread': 'Новый ответ', + 'chat.sidebar.workingFolder': 'Рабочая папка: {folder}', 'chat.newWindowPrompt': 'Чем я могу помочь тебе сегодня?', 'chat.welcomeSuggestion.calendarToday': 'Что у меня сегодня в календаре?', 'chat.welcomeSuggestion.unreadEmail': 'Сделай сводку моих непрочитанных писем.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index a2a79e1bc99..0794d2e052f 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -441,6 +441,19 @@ const messages: TranslationMap = { 'openrouterFree.error': '无法切换到 OpenRouter 免费模型。请检查 OpenRouter 登录后重试。', 'chat.newThread': '新对话', 'chat.newConversation': '新对话', + 'chat.sidebar.searchPlaceholder': '搜索对话', + 'chat.sidebar.clearSearch': '清除搜索', + 'chat.sidebar.noMatches': '没有匹配的对话', + 'chat.sidebar.group.pinned': '已置顶', + 'chat.sidebar.group.today': '今天', + 'chat.sidebar.group.yesterday': '昨天', + 'chat.sidebar.group.previous7Days': '过去 7 天', + 'chat.sidebar.group.previous30Days': '过去 30 天', + 'chat.sidebar.group.older': '更早', + 'chat.sidebar.pinThread': '置顶对话', + 'chat.sidebar.unpinThread': '取消置顶对话', + 'chat.sidebar.unread': '新回复', + 'chat.sidebar.workingFolder': '工作文件夹:{folder}', 'chat.newWindowPrompt': '今天我能帮您做什么?', 'chat.welcomeSuggestion.calendarToday': '我今天的日程有什么?', 'chat.welcomeSuggestion.unreadEmail': '总结我的未读邮件。', From 1fbbc8637fb464b1260fbbfb9f6d0a5bc3daf3d7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:18:07 +0300 Subject: [PATCH 014/126] feat(threads): add working_dir field to conversation threads Thread creation and summary types now carry an optional working directory, defaulting to None everywhere threads are constructed. The thread list sidebar and French locale also received minor formatting cleanups. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/threadList/ThreadList.tsx | 18 +++++++++++++----- app/src/lib/i18n/fr.ts | 2 +- .../tools/spawn_async_subagent_tests.rs | 1 + .../orchestration/tools/spawn_subagent.rs | 1 + .../tools/spawn_worker_thread_tests.rs | 2 ++ .../agent/orchestration/tools/worker_thread.rs | 1 + .../subagent_host/ops/graph_policy_tests.rs | 1 + .../src/channels/host/adapters.rs | 1 + .../src/channels/host/remote_control.rs | 1 + .../src/memory/backfill_tests.rs | 1 + crates/openhuman-core/src/threads/ops/crud.rs | 2 ++ .../threads/ops/crud_message_append_tests.rs | 2 ++ .../src/threads/ops/edit_turn_state_tests.rs | 1 + .../openhuman-core/src/threads/ops/support.rs | 1 + crates/openhuman-core/src/threads/ops_tests.rs | 2 ++ .../src/threads/store/blocking_tests.rs | 1 + crates/openhuman-core/src/threads/store/bus.rs | 1 + .../src/threads/welcome_migration_tests.rs | 1 + .../src/web_chat/presentation_tests.rs | 1 + .../src/web_chat/reply_persistence_tests.rs | 1 + tests/in_process/transcript_search_e2e.rs | 1 + vendor/tinyagents | 2 +- 22 files changed, 38 insertions(+), 7 deletions(-) diff --git a/app/src/features/conversations/threadList/ThreadList.tsx b/app/src/features/conversations/threadList/ThreadList.tsx index e3431306143..a89b930dfc9 100644 --- a/app/src/features/conversations/threadList/ThreadList.tsx +++ b/app/src/features/conversations/threadList/ThreadList.tsx @@ -161,9 +161,7 @@ export function ThreadList({ data-running={isThreadRunning?.(thread.id) ? 'true' : undefined} aria-busy={isThreadRunning?.(thread.id) || undefined} className={`truncate flex-1 text-[14px] ${ - selectedThreadId === thread.id - ? 'font-semibold text-content' - : 'text-content-muted' + selectedThreadId === thread.id ? 'font-semibold text-content' : 'text-content-muted' } ${running ? 'shimmer motion-reduce:animate-none' : ''}`}> {resolveTitle(thread.id)}

@@ -343,8 +341,18 @@ export function ThreadList({ aria-label={t('chat.sidebar.clearSearch')} title={t('chat.sidebar.clearSearch')} className="absolute right-1.5 inline-flex h-5 w-5 items-center justify-center rounded text-content-faint transition-colors hover:bg-surface/60 hover:text-content-secondary"> - + )} + {onTogglePin && ( + + )} + + + onChange(null)} + title={defaultDir ?? undefined}> + + + {defaultDir + ? t('composer.workspace.defaultWithName').replace( + '{folder}', + folderBasename(defaultDir) + ) + : t('composer.workspace.default')} + + + {recent.length > 0 && ( +

+ {t('composer.workspace.recent')} +

+ )} + {recent.map(dir => ( + onChange(dir)} + title={dir}> + + {folderBasename(dir)} + + ))} + {onChooseFolder && ( + onChooseFolder()}> + {t('composer.workspace.choose')} + + )} +
+ + {error ? ( + + {error} + + ) : ( + + {t('composer.workspace.hint')} + + )} +
+ ); +} + +export default WorkspacePicker; From 31f3fc50680b8eeaa0692bdda531c2a71e815771 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:31:31 +0300 Subject: [PATCH 025/126] chore(config): remove unused tauri config helpers Dropped several helper functions from the config module that were no longer referenced anywhere in the app. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/utils/tauriCommands/config.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/utils/tauriCommands/config.ts b/app/src/utils/tauriCommands/config.ts index 09601f047ae..0beb59023de 100644 --- a/app/src/utils/tauriCommands/config.ts +++ b/app/src/utils/tauriCommands/config.ts @@ -122,6 +122,11 @@ export interface RuntimeSettingsUpdate { reasoning_enabled?: boolean | null; /** Default thinking level for agent turns; `''` clears it to the provider default. */ reasoning_effort?: string | null; + /** + * When set, `reasoning_effort` is saved as this model id's own level + * (`runtime.reasoning_effort_by_model`) and `''` removes the model's entry. + */ + reasoning_effort_model?: string | null; } export interface BrowserSettingsUpdate { @@ -216,6 +221,8 @@ export interface ClientConfig { * provider decides. Absent on cores that predate it. */ reasoning_effort?: string | null; + /** Per-model thinking levels keyed by model id; outrank `reasoning_effort`. */ + reasoning_effort_by_model?: Record | null; app_version: string; api_key_set: boolean; /** Legacy per-task-hint model overrides (deprecated; will be removed). */ From 24892301483835e8fd4bccfc4bff75417e65b6af Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:32:05 +0300 Subject: [PATCH 026/126] feat(conversations): add reasoning effort picker Add a picker for choosing the reasoning effort level in conversations, along with tests covering its behaviour. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../aui/ReasoningEffortPicker.test.tsx | 29 +++++++- .../aui/ReasoningEffortPicker.tsx | 67 +++++++++++++++++-- 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/app/src/features/conversations/aui/ReasoningEffortPicker.test.tsx b/app/src/features/conversations/aui/ReasoningEffortPicker.test.tsx index bd70deca449..3c51a5b1c7a 100644 --- a/app/src/features/conversations/aui/ReasoningEffortPicker.test.tsx +++ b/app/src/features/conversations/aui/ReasoningEffortPicker.test.tsx @@ -1,7 +1,11 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { ReasoningEffortPicker, toReasoningEffortChoice } from './ReasoningEffortPicker'; +import { + gaugeAngleFor, + ReasoningEffortPicker, + toReasoningEffortChoice, +} from './ReasoningEffortPicker'; vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) })); @@ -15,6 +19,7 @@ describe('ReasoningEffortPicker', () => { expect(Array.from(select.options).map(o => o.value)).toEqual([ 'default', 'none', + 'minimal', 'low', 'medium', 'high', @@ -33,12 +38,32 @@ describe('ReasoningEffortPicker', () => { }); }); +describe('gaugeAngleFor', () => { + it('sweeps from off to max and points up for the provider default', () => { + expect(gaugeAngleFor('default')).toBe(0); + expect(gaugeAngleFor('none')).toBe(-120); + expect(gaugeAngleFor('xhigh')).toBe(120); + expect(gaugeAngleFor('low')).toBeLessThan(gaugeAngleFor('high')); + }); +}); + +describe('ReasoningEffortPicker per-model tooltip', () => { + it('names the model the level is remembered for', () => { + render(); + expect(screen.getByTestId('composer-reasoning-effort')).toHaveAttribute( + 'title', + 'composer.reasoning.forModel' + ); + }); +}); + describe('toReasoningEffortChoice', () => { it('normalizes config values and aliases', () => { expect(toReasoningEffortChoice('HIGH')).toBe('high'); expect(toReasoningEffortChoice('off')).toBe('none'); expect(toReasoningEffortChoice('max')).toBe('xhigh'); - expect(toReasoningEffortChoice('minimal')).toBe('default'); + expect(toReasoningEffortChoice('minimal')).toBe('minimal'); + expect(toReasoningEffortChoice('turbo')).toBe('default'); expect(toReasoningEffortChoice(null)).toBe('default'); expect(toReasoningEffortChoice('')).toBe('default'); }); diff --git a/app/src/features/conversations/aui/ReasoningEffortPicker.tsx b/app/src/features/conversations/aui/ReasoningEffortPicker.tsx index e3da2f3fa93..8d6c6d9de82 100644 --- a/app/src/features/conversations/aui/ReasoningEffortPicker.tsx +++ b/app/src/features/conversations/aui/ReasoningEffortPicker.tsx @@ -3,10 +3,22 @@ * picks the reasoning effort every send asks for (`reasoning_effort` on * `openhuman.channel_web_chat`), which the core turns into the provider's * reasoning setting. `default` leaves the choice to the provider. + * + * The level is remembered per model (`runtime.reasoning_effort_by_model`), so + * switching models brings back the level last used with that model. The gauge + * beside the label sweeps with the level, after OpenClaw's effort pill. */ import { useT } from '../../../lib/i18n/I18nContext'; -export const REASONING_EFFORTS = ['default', 'none', 'low', 'medium', 'high', 'xhigh'] as const; +export const REASONING_EFFORTS = [ + 'default', + 'none', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', +] as const; export type ReasoningEffortChoice = (typeof REASONING_EFFORTS)[number]; @@ -20,33 +32,78 @@ export function toReasoningEffortChoice(value: string | null | undefined): Reaso : 'default'; } +/** + * Needle angle for a level: -120° (off) to +120° (max). `default` points + * straight up, since the provider decides. + */ +export function gaugeAngleFor(value: ReasoningEffortChoice): number { + if (value === 'default') return 0; + const levels = REASONING_EFFORTS.slice(1); + const index = levels.indexOf(value); + return -120 + (240 * index) / (levels.length - 1); +} + +function ThinkingGauge({ value }: { value: ReasoningEffortChoice }) { + return ( + + ); +} + export function ReasoningEffortPicker({ value, onChange, disabled = false, + modelLabel, }: { value: ReasoningEffortChoice; onChange: (value: ReasoningEffortChoice) => void; disabled?: boolean; + /** The model this level is remembered for; named in the tooltip. */ + modelLabel?: string | null; }) { const { t } = useT(); const label = t('composer.reasoning.label'); + const title = modelLabel + ? t('composer.reasoning.forModel').replace('{model}', modelLabel) + : label; return ( - onChange(toReasoningEffortChoice(event.target.value))} - className="h-7 min-w-0 cursor-pointer rounded-md border-none bg-transparent px-2 text-xs font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content focus:outline-none focus-visible:ring-1 focus-visible:ring-line disabled:opacity-50"> + className="h-7 min-w-0 cursor-pointer rounded-md border-none bg-transparent pr-2 text-xs font-medium text-inherit focus:outline-none focus-visible:ring-1 focus-visible:ring-line disabled:opacity-50"> {REASONING_EFFORTS.map(effort => ( ))} - + + ); } From 2adf0ad9955cf1c4c2bf9bfd5a1fe3973a70d906 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:32:20 +0300 Subject: [PATCH 027/126] feat(conversations): add conversation list view Adds a conversations feature component that renders the list of conversations for the current user. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../features/conversations/Conversations.tsx | 58 +++++++++++++++---- 1 file changed, 47 insertions(+), 11 deletions(-) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 4afef7de3f4..ff7581b0210 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -455,17 +455,33 @@ const Conversations = ({ number | null | undefined >(undefined); // The composer's thinking level. Sent with every turn (`reasoning_effort`) - // so it applies immediately, and written to the core's - // `runtime.reasoning_effort` so it survives a restart and is the default for - // turns the composer does not start — the same split as the model pick. - const [composerReasoningEffort, setComposerReasoningEffort] = + // so it applies immediately, and written to the core so it survives a + // restart and is the default for turns the composer does not start — the + // same split as the model pick. It is remembered per model + // (`runtime.reasoning_effort_by_model`) when a model is picked, and globally + // (`runtime.reasoning_effort`) for the managed default; a model with no + // level of its own shows, and sends, the global one. + const [globalReasoningEffort, setGlobalReasoningEffort] = useState('default'); + const [reasoningEffortByModel, setReasoningEffortByModel] = useState< + Record + >({}); + const composerReasoningEffort: ReasoningEffortChoice = + (composerModelOverride ? reasoningEffortByModel[composerModelOverride] : undefined) ?? + globalReasoningEffort; useEffect(() => { let cancelled = false; void openhumanGetClientConfig() .then(res => { if (!cancelled) { - setComposerReasoningEffort(toReasoningEffortChoice(res.result?.reasoning_effort)); + setGlobalReasoningEffort(toReasoningEffortChoice(res.result?.reasoning_effort)); + const byModel: Record = {}; + for (const [model, effort] of Object.entries( + res.result?.reasoning_effort_by_model ?? {} + )) { + byModel[model] = toReasoningEffortChoice(effort); + } + setReasoningEffortByModel(byModel); } }) .catch((err: unknown) => { @@ -477,19 +493,39 @@ const Conversations = ({ cancelled = true; }; }, []); - const applyComposerReasoningEffort = useCallback((value: ReasoningEffortChoice) => { - setComposerReasoningEffort(value); - void openhumanUpdateRuntimeSettings({ reasoning_effort: value === 'default' ? '' : value }) - .then(() => { - console.debug('[chat][composer-reasoning] persisted reasoning_effort', { effort: value }); + const applyComposerReasoningEffort = useCallback( + (value: ReasoningEffortChoice) => { + const model = composerModelOverride; + if (model) { + // `default` on a model drops its own level, falling back to the global one. + setReasoningEffortByModel(prev => { + const next = { ...prev }; + if (value === 'default') delete next[model]; + else next[model] = value; + return next; + }); + } else { + setGlobalReasoningEffort(value); + } + void openhumanUpdateRuntimeSettings({ + reasoning_effort: value === 'default' ? '' : value, + ...(model ? { reasoning_effort_model: model } : {}), }) + .then(() => { + console.debug('[chat][composer-reasoning] persisted reasoning_effort', { + effort: value, + perModel: Boolean(model), + }); + }) .catch((err: unknown) => { // The per-send value still applies; only persistence failed. console.warn('[chat][composer-reasoning] failed to persist reasoning_effort', { message: err instanceof Error ? err.message : String(err), }); }); - }, []); + }, + [composerModelOverride] + ); const applyComposerModel = useCallback((value: string | null, contextWindow?: number | null) => { setComposerModelOverride(value); setComposerModelContextWindow(contextWindow ?? null); From 97547d504d924cba7bfbdd21c052f2e100b4e752 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:32:43 +0300 Subject: [PATCH 028/126] feat(conversations): add conversation list view Introduce a conversations feature component that renders the list of conversations for the user. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/features/conversations/Conversations.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index ff7581b0210..f62c7c43031 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -466,8 +466,13 @@ const Conversations = ({ const [reasoningEffortByModel, setReasoningEffortByModel] = useState< Record >({}); + // The model a turn runs on: this session's pick, else the persisted + // `default_model` (the core keys per-model levels by it after a restart, + // before anything is picked here). + const [persistedDefaultModel, setPersistedDefaultModel] = useState(null); + const reasoningModel = composerModelOverride ?? persistedDefaultModel; const composerReasoningEffort: ReasoningEffortChoice = - (composerModelOverride ? reasoningEffortByModel[composerModelOverride] : undefined) ?? + (reasoningModel ? reasoningEffortByModel[reasoningModel] : undefined) ?? globalReasoningEffort; useEffect(() => { let cancelled = false; @@ -475,6 +480,7 @@ const Conversations = ({ .then(res => { if (!cancelled) { setGlobalReasoningEffort(toReasoningEffortChoice(res.result?.reasoning_effort)); + setPersistedDefaultModel(res.result?.default_model?.trim() || null); const byModel: Record = {}; for (const [model, effort] of Object.entries( res.result?.reasoning_effort_by_model ?? {} @@ -495,7 +501,7 @@ const Conversations = ({ }, []); const applyComposerReasoningEffort = useCallback( (value: ReasoningEffortChoice) => { - const model = composerModelOverride; + const model = reasoningModel; if (model) { // `default` on a model drops its own level, falling back to the global one. setReasoningEffortByModel(prev => { @@ -524,10 +530,11 @@ const Conversations = ({ }); }); }, - [composerModelOverride] + [reasoningModel] ); const applyComposerModel = useCallback((value: string | null, contextWindow?: number | null) => { setComposerModelOverride(value); + setPersistedDefaultModel(value); setComposerModelContextWindow(contextWindow ?? null); void callCoreRpc({ method: 'openhuman.inference_update_model_settings', @@ -1982,6 +1989,7 @@ const Conversations = ({ {chatFilesChip} From ceea3e001bb419e4bf4f712d8adfd4c761584892 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:33:01 +0300 Subject: [PATCH 029/126] feat(conversations): add thread workspace chip Introduce a chip component that surfaces the active thread's workspace in the conversation header. This gives users a quick way to see and switch context without leaving the thread view. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/aui/ThreadWorkspaceChip.tsx | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 app/src/features/conversations/aui/ThreadWorkspaceChip.tsx diff --git a/app/src/features/conversations/aui/ThreadWorkspaceChip.tsx b/app/src/features/conversations/aui/ThreadWorkspaceChip.tsx new file mode 100644 index 00000000000..7e4088221d4 --- /dev/null +++ b/app/src/features/conversations/aui/ThreadWorkspaceChip.tsx @@ -0,0 +1,89 @@ +/** + * Store-connected {@link WorkspacePicker} for one thread. Renders only while + * the thread has no messages — the core refuses to move a started thread — + * and binds the choice through `updateThreadWorkingDir`. + */ +import debugFactory from 'debug'; +import { useEffect, useMemo, useState } from 'react'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import { useAppDispatch, useAppSelector } from '../../../store/hooks'; +import { updateThreadWorkingDir } from '../../../store/threadSlice'; +import { openhumanGetAgentPaths } from '../../../utils/tauriCommands/config'; +import { pickDirectoryNatively } from '../../../utils/tauriCommands/directoryPicker'; +import { isTauri } from '../../../utils/tauriCommands/common'; +import { recentWorkingFolders, WorkspacePicker } from './WorkspacePicker'; + +const debug = debugFactory('conversations:workspace'); + +export function ThreadWorkspaceChip({ threadId }: { threadId: string | null }) { + const { t } = useT(); + const dispatch = useAppDispatch(); + const threads = useAppSelector(state => state.thread.threads); + const thread = threadId ? threads.find(candidate => candidate.id === threadId) : undefined; + const [defaultDir, setDefaultDir] = useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + void openhumanGetAgentPaths() + .then(res => { + if (!cancelled) setDefaultDir(res.result?.action_dir ?? null); + }) + .catch((err: unknown) => { + debug('agent paths unavailable: %s', err instanceof Error ? err.message : String(err)); + }); + return () => { + cancelled = true; + }; + }, []); + + // A new thread starts with no error from the previous one. + useEffect(() => setError(null), [threadId]); + + const value = thread?.actionDir ?? null; + const recent = useMemo( + () => recentWorkingFolders(threads, [value, defaultDir]), + [threads, value, defaultDir] + ); + + if (!thread || thread.messageCount > 0) return null; + + const bind = (dir: string | null) => { + if (dir === value) return; + setSaving(true); + setError(null); + debug('[chat][workspace] bind thread=%s default=%s', thread.id, dir === null); + void dispatch(updateThreadWorkingDir({ threadId: thread.id, actionDir: dir })) + .unwrap() + .catch((err: unknown) => { + debug('[chat][workspace] bind failed thread=%s', thread.id); + setError(typeof err === 'string' && err ? err : t('composer.workspace.error')); + }) + .finally(() => setSaving(false)); + }; + + const chooseFolder = async () => { + const picked = await pickDirectoryNatively(); + if (picked.ok) { + bind(picked.path); + } else if (picked.reason === 'failed') { + setError(t('composer.workspace.error')); + } + }; + + return ( + void chooseFolder() : undefined} + disabled={saving} + error={error} + /> + ); +} + +export default ThreadWorkspaceChip; From f4d68d66e6ddeaed8b3c3c271cc73538319f689c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:33:41 +0300 Subject: [PATCH 030/126] feat(conversations): add empty state for conversations list Show a localized empty state when there are no conversations to display, so the list no longer renders as a blank panel. Added the corresponding translation strings across all supported locales. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/features/conversations/Conversations.tsx | 5 +++++ app/src/lib/i18n/ar.ts | 9 +++++++++ app/src/lib/i18n/bn.ts | 9 +++++++++ app/src/lib/i18n/de.ts | 9 +++++++++ app/src/lib/i18n/en.ts | 9 +++++++++ app/src/lib/i18n/es.ts | 9 +++++++++ app/src/lib/i18n/fr.ts | 9 +++++++++ app/src/lib/i18n/hi.ts | 9 +++++++++ app/src/lib/i18n/id.ts | 9 +++++++++ app/src/lib/i18n/it.ts | 9 +++++++++ app/src/lib/i18n/ko.ts | 9 +++++++++ app/src/lib/i18n/pl.ts | 9 +++++++++ app/src/lib/i18n/pt.ts | 9 +++++++++ app/src/lib/i18n/ru.ts | 9 +++++++++ app/src/lib/i18n/zh-CN.ts | 9 +++++++++ 15 files changed, 131 insertions(+) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index f62c7c43031..231424c49bf 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -105,6 +105,7 @@ import { ReasoningEffortPicker, toReasoningEffortChoice, } from './aui/ReasoningEffortPicker'; +import { ThreadWorkspaceChip } from './aui/ThreadWorkspaceChip'; import { useChatSurfaceRegistration } from './hooks/useChatSurfaceRegistration'; import { ThreadList } from './threadList/ThreadList'; import { useThreadPins } from './threadList/useThreadPins'; @@ -1980,6 +1981,10 @@ const Conversations = ({ {liveArtifactDeck} {/* The core's run queue for this thread; renders nothing while empty. */} + {/* Where this conversation works. Last, so it sits directly on the + input; renders only while the thread has no messages, since the + core fixes the folder at the first send. */} + ); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 7236b36e672..b40e8aa46da 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4253,6 +4253,15 @@ const messages: TranslationMap = { 'composer.reasoning.medium': 'التفكير: متوسط', 'composer.reasoning.high': 'التفكير: مرتفع', 'composer.reasoning.xhigh': 'التفكير: أقصى', + 'composer.reasoning.minimal': 'التفكير: أدنى', + 'composer.reasoning.forModel': 'مستوى التفكير لـ {model}', + 'composer.workspace.label': 'مجلد العمل', + 'composer.workspace.default': 'المجلد الافتراضي', + 'composer.workspace.defaultWithName': 'افتراضي · {folder}', + 'composer.workspace.recent': 'الأخيرة', + 'composer.workspace.choose': 'اختر مجلدًا…', + 'composer.workspace.hint': 'اختر مكان عمل هذه المحادثة', + 'composer.workspace.error': 'تعذّر استخدام هذا المجلد', 'composer.settings.model': 'النموذج', 'composer.settings.temperature': 'درجة الحرارة', 'composer.settings.chooseModel': 'اختر نموذجًا آخر…', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index cae1be34d46..cdd0eca6b73 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4332,6 +4332,15 @@ const messages: TranslationMap = { 'composer.reasoning.medium': 'চিন্তা: মাঝারি', 'composer.reasoning.high': 'চিন্তা: বেশি', 'composer.reasoning.xhigh': 'চিন্তা: সর্বোচ্চ', + 'composer.reasoning.minimal': 'চিন্তা: ন্যূনতম', + 'composer.reasoning.forModel': '{model}-এর চিন্তার স্তর', + 'composer.workspace.label': 'কাজের ফোল্ডার', + 'composer.workspace.default': 'ডিফল্ট ফোল্ডার', + 'composer.workspace.defaultWithName': 'ডিফল্ট · {folder}', + 'composer.workspace.recent': 'সাম্প্রতিক', + 'composer.workspace.choose': 'ফোল্ডার বেছে নিন…', + 'composer.workspace.hint': 'এই কথোপকথন কোথায় কাজ করবে তা বেছে নিন', + 'composer.workspace.error': 'ওই ফোল্ডারটি ব্যবহার করা যায়নি', 'composer.settings.model': 'মডেল', 'composer.settings.temperature': 'টেম্পারেচার', 'composer.settings.chooseModel': 'অন্য মডেল বেছে নিন…', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 5fa73aaee70..76c2f22c084 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4440,6 +4440,15 @@ const messages: TranslationMap = { 'composer.reasoning.medium': 'Denken: mittel', 'composer.reasoning.high': 'Denken: hoch', 'composer.reasoning.xhigh': 'Denken: maximal', + 'composer.reasoning.minimal': 'Denken: minimal', + 'composer.reasoning.forModel': 'Denkstufe für {model}', + 'composer.workspace.label': 'Arbeitsordner', + 'composer.workspace.default': 'Standardordner', + 'composer.workspace.defaultWithName': 'Standard · {folder}', + 'composer.workspace.recent': 'Zuletzt verwendet', + 'composer.workspace.choose': 'Ordner auswählen…', + 'composer.workspace.hint': 'Wähle, wo diese Unterhaltung arbeitet', + 'composer.workspace.error': 'Dieser Ordner konnte nicht verwendet werden', 'composer.settings.model': 'Modell', 'composer.settings.temperature': 'Temperatur', 'composer.settings.chooseModel': 'Anderes Modell wählen…', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 32526bf0f84..d6ef60e2931 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -4986,6 +4986,15 @@ const en: TranslationMap = { 'composer.reasoning.medium': 'Thinking: medium', 'composer.reasoning.high': 'Thinking: high', 'composer.reasoning.xhigh': 'Thinking: max', + 'composer.reasoning.minimal': 'Thinking: minimal', + 'composer.reasoning.forModel': 'Thinking level for {model}', + 'composer.workspace.label': 'Working folder', + 'composer.workspace.default': 'Default folder', + 'composer.workspace.defaultWithName': 'Default · {folder}', + 'composer.workspace.recent': 'Recent', + 'composer.workspace.choose': 'Choose folder…', + 'composer.workspace.hint': 'Pick where this conversation works', + 'composer.workspace.error': "Couldn't use that folder", 'composer.settings.model': 'Model', 'composer.settings.temperature': 'Temperature', 'composer.settings.chooseModel': 'Choose another model…', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 951ade9dc1e..91e4e115972 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4404,6 +4404,15 @@ const messages: TranslationMap = { 'composer.reasoning.medium': 'Razonamiento: medio', 'composer.reasoning.high': 'Razonamiento: alto', 'composer.reasoning.xhigh': 'Razonamiento: máximo', + 'composer.reasoning.minimal': 'Razonamiento: mínimo', + 'composer.reasoning.forModel': 'Nivel de razonamiento para {model}', + 'composer.workspace.label': 'Carpeta de trabajo', + 'composer.workspace.default': 'Carpeta predeterminada', + 'composer.workspace.defaultWithName': 'Predeterminada · {folder}', + 'composer.workspace.recent': 'Recientes', + 'composer.workspace.choose': 'Elegir carpeta…', + 'composer.workspace.hint': 'Elige dónde trabaja esta conversación', + 'composer.workspace.error': 'No se pudo usar esa carpeta', 'composer.settings.model': 'Modelo', 'composer.settings.temperature': 'Temperatura', 'composer.settings.chooseModel': 'Elegir otro modelo…', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index b354c22d6c7..c63df2b6273 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4421,6 +4421,15 @@ const messages: TranslationMap = { 'composer.reasoning.medium': 'Réflexion : moyenne', 'composer.reasoning.high': 'Réflexion : élevée', 'composer.reasoning.xhigh': 'Réflexion : maximale', + 'composer.reasoning.minimal': 'Réflexion : minimale', + 'composer.reasoning.forModel': 'Niveau de réflexion pour {model}', + 'composer.workspace.label': 'Dossier de travail', + 'composer.workspace.default': 'Dossier par défaut', + 'composer.workspace.defaultWithName': 'Par défaut · {folder}', + 'composer.workspace.recent': 'Récents', + 'composer.workspace.choose': 'Choisir un dossier…', + 'composer.workspace.hint': 'Choisissez où travaille cette conversation', + 'composer.workspace.error': "Impossible d'utiliser ce dossier", 'composer.settings.model': 'Modèle', 'composer.settings.temperature': 'Température', 'composer.settings.chooseModel': 'Choisir un autre modèle…', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 1354e517a52..f2bf036f753 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4331,6 +4331,15 @@ const messages: TranslationMap = { 'composer.reasoning.medium': 'सोच: मध्यम', 'composer.reasoning.high': 'सोच: उच्च', 'composer.reasoning.xhigh': 'सोच: अधिकतम', + 'composer.reasoning.minimal': 'सोच: न्यूनतम', + 'composer.reasoning.forModel': '{model} के लिए सोच का स्तर', + 'composer.workspace.label': 'कार्य फ़ोल्डर', + 'composer.workspace.default': 'डिफ़ॉल्ट फ़ोल्डर', + 'composer.workspace.defaultWithName': 'डिफ़ॉल्ट · {folder}', + 'composer.workspace.recent': 'हाल के', + 'composer.workspace.choose': 'फ़ोल्डर चुनें…', + 'composer.workspace.hint': 'चुनें कि यह बातचीत कहाँ काम करेगी', + 'composer.workspace.error': 'उस फ़ोल्डर का उपयोग नहीं हो सका', 'composer.settings.model': 'मॉडल', 'composer.settings.temperature': 'टेम्परेचर', 'composer.settings.chooseModel': 'दूसरा मॉडल चुनें…', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index be03e863cce..31c35c737a4 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4359,6 +4359,15 @@ const messages: TranslationMap = { 'composer.reasoning.medium': 'Penalaran: sedang', 'composer.reasoning.high': 'Penalaran: tinggi', 'composer.reasoning.xhigh': 'Penalaran: maksimum', + 'composer.reasoning.minimal': 'Berpikir: minimal', + 'composer.reasoning.forModel': 'Tingkat berpikir untuk {model}', + 'composer.workspace.label': 'Folder kerja', + 'composer.workspace.default': 'Folder bawaan', + 'composer.workspace.defaultWithName': 'Bawaan · {folder}', + 'composer.workspace.recent': 'Terbaru', + 'composer.workspace.choose': 'Pilih folder…', + 'composer.workspace.hint': 'Pilih tempat percakapan ini bekerja', + 'composer.workspace.error': 'Tidak dapat menggunakan folder itu', 'composer.settings.model': 'Model', 'composer.settings.temperature': 'Suhu', 'composer.settings.chooseModel': 'Pilih model lain…', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index c2811d76dc2..0a97dfd7906 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4397,6 +4397,15 @@ const messages: TranslationMap = { 'composer.reasoning.medium': 'Ragionamento: medio', 'composer.reasoning.high': 'Ragionamento: alto', 'composer.reasoning.xhigh': 'Ragionamento: massimo', + 'composer.reasoning.minimal': 'Ragionamento: minimo', + 'composer.reasoning.forModel': 'Livello di ragionamento per {model}', + 'composer.workspace.label': 'Cartella di lavoro', + 'composer.workspace.default': 'Cartella predefinita', + 'composer.workspace.defaultWithName': 'Predefinita · {folder}', + 'composer.workspace.recent': 'Recenti', + 'composer.workspace.choose': 'Scegli cartella…', + 'composer.workspace.hint': 'Scegli dove lavora questa conversazione', + 'composer.workspace.error': 'Impossibile usare quella cartella', 'composer.settings.model': 'Modello', 'composer.settings.temperature': 'Temperatura', 'composer.settings.chooseModel': 'Scegli un altro modello…', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 6cabe614595..2ef154e1e80 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4296,6 +4296,15 @@ const messages: TranslationMap = { 'composer.reasoning.medium': '사고: 보통', 'composer.reasoning.high': '사고: 높음', 'composer.reasoning.xhigh': '사고: 최대', + 'composer.reasoning.minimal': '생각: 최소', + 'composer.reasoning.forModel': '{model}의 생각 수준', + 'composer.workspace.label': '작업 폴더', + 'composer.workspace.default': '기본 폴더', + 'composer.workspace.defaultWithName': '기본 · {folder}', + 'composer.workspace.recent': '최근', + 'composer.workspace.choose': '폴더 선택…', + 'composer.workspace.hint': '이 대화가 작업할 위치를 선택하세요', + 'composer.workspace.error': '해당 폴더를 사용할 수 없습니다', 'composer.settings.model': '모델', 'composer.settings.temperature': '온도', 'composer.settings.chooseModel': '다른 모델 선택…', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 49d68efe212..3e6ba0f71a2 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4387,6 +4387,15 @@ const messages: TranslationMap = { 'composer.reasoning.medium': 'Rozumowanie: średnie', 'composer.reasoning.high': 'Rozumowanie: wysokie', 'composer.reasoning.xhigh': 'Rozumowanie: maksymalne', + 'composer.reasoning.minimal': 'Myślenie: minimalne', + 'composer.reasoning.forModel': 'Poziom myślenia dla {model}', + 'composer.workspace.label': 'Folder roboczy', + 'composer.workspace.default': 'Folder domyślny', + 'composer.workspace.defaultWithName': 'Domyślny · {folder}', + 'composer.workspace.recent': 'Ostatnie', + 'composer.workspace.choose': 'Wybierz folder…', + 'composer.workspace.hint': 'Wybierz, gdzie pracuje ta rozmowa', + 'composer.workspace.error': 'Nie można użyć tego folderu', 'composer.settings.model': 'Model', 'composer.settings.temperature': 'Temperatura', 'composer.settings.chooseModel': 'Wybierz inny model…', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index d39a47933ae..5a856be58cc 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4385,6 +4385,15 @@ const messages: TranslationMap = { 'composer.reasoning.medium': 'Raciocínio: médio', 'composer.reasoning.high': 'Raciocínio: alto', 'composer.reasoning.xhigh': 'Raciocínio: máximo', + 'composer.reasoning.minimal': 'Raciocínio: mínimo', + 'composer.reasoning.forModel': 'Nível de raciocínio para {model}', + 'composer.workspace.label': 'Pasta de trabalho', + 'composer.workspace.default': 'Pasta padrão', + 'composer.workspace.defaultWithName': 'Padrão · {folder}', + 'composer.workspace.recent': 'Recentes', + 'composer.workspace.choose': 'Escolher pasta…', + 'composer.workspace.hint': 'Escolha onde esta conversa trabalha', + 'composer.workspace.error': 'Não foi possível usar essa pasta', 'composer.settings.model': 'Modelo', 'composer.settings.temperature': 'Temperatura', 'composer.settings.chooseModel': 'Escolher outro modelo…', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index a732860f78b..d477a26650c 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4373,6 +4373,15 @@ const messages: TranslationMap = { 'composer.reasoning.medium': 'Рассуждение: среднее', 'composer.reasoning.high': 'Рассуждение: высокое', 'composer.reasoning.xhigh': 'Рассуждение: максимум', + 'composer.reasoning.minimal': 'Размышление: минимальное', + 'composer.reasoning.forModel': 'Уровень размышления для {model}', + 'composer.workspace.label': 'Рабочая папка', + 'composer.workspace.default': 'Папка по умолчанию', + 'composer.workspace.defaultWithName': 'По умолчанию · {folder}', + 'composer.workspace.recent': 'Недавние', + 'composer.workspace.choose': 'Выбрать папку…', + 'composer.workspace.hint': 'Выберите, где будет работать этот разговор', + 'composer.workspace.error': 'Не удалось использовать эту папку', 'composer.settings.model': 'Модель', 'composer.settings.temperature': 'Температура', 'composer.settings.chooseModel': 'Выбрать другую модель…', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 0794d2e052f..e0752e9fa7e 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4093,6 +4093,15 @@ const messages: TranslationMap = { 'composer.reasoning.medium': '思考:中', 'composer.reasoning.high': '思考:高', 'composer.reasoning.xhigh': '思考:最高', + 'composer.reasoning.minimal': '思考:最少', + 'composer.reasoning.forModel': '{model} 的思考级别', + 'composer.workspace.label': '工作文件夹', + 'composer.workspace.default': '默认文件夹', + 'composer.workspace.defaultWithName': '默认 · {folder}', + 'composer.workspace.recent': '最近', + 'composer.workspace.choose': '选择文件夹…', + 'composer.workspace.hint': '选择此对话的工作位置', + 'composer.workspace.error': '无法使用该文件夹', 'composer.settings.model': '模型', 'composer.settings.temperature': '温度', 'composer.settings.chooseModel': '选择其他模型…', From 28452a3e42e6f0cdb6873d93d7da6a6eca6a762c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:34:47 +0300 Subject: [PATCH 031/126] feat(conversations): add workspace and reasoning effort pickers Adds a workspace picker and a reasoning effort picker to the conversation composer, along with a chip showing the active workspace on the thread. This lets users choose which workspace a conversation runs against and control the model's reasoning effort per thread. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../features/conversations/Conversations.tsx | 13 +++++----- .../aui/ReasoningEffortPicker.tsx | 26 +++++++++---------- .../conversations/aui/ThreadWorkspaceChip.tsx | 2 +- .../conversations/aui/WorkspacePicker.tsx | 13 ++++++---- 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 231424c49bf..2bf43932add 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -473,8 +473,7 @@ const Conversations = ({ const [persistedDefaultModel, setPersistedDefaultModel] = useState(null); const reasoningModel = composerModelOverride ?? persistedDefaultModel; const composerReasoningEffort: ReasoningEffortChoice = - (reasoningModel ? reasoningEffortByModel[reasoningModel] : undefined) ?? - globalReasoningEffort; + (reasoningModel ? reasoningEffortByModel[reasoningModel] : undefined) ?? globalReasoningEffort; useEffect(() => { let cancelled = false; void openhumanGetClientConfig() @@ -524,12 +523,12 @@ const Conversations = ({ perModel: Boolean(model), }); }) - .catch((err: unknown) => { - // The per-send value still applies; only persistence failed. - console.warn('[chat][composer-reasoning] failed to persist reasoning_effort', { - message: err instanceof Error ? err.message : String(err), + .catch((err: unknown) => { + // The per-send value still applies; only persistence failed. + console.warn('[chat][composer-reasoning] failed to persist reasoning_effort', { + message: err instanceof Error ? err.message : String(err), + }); }); - }); }, [reasoningModel] ); diff --git a/app/src/features/conversations/aui/ReasoningEffortPicker.tsx b/app/src/features/conversations/aui/ReasoningEffortPicker.tsx index 8d6c6d9de82..9a6fd9165b1 100644 --- a/app/src/features/conversations/aui/ReasoningEffortPicker.tsx +++ b/app/src/features/conversations/aui/ReasoningEffortPicker.tsx @@ -89,19 +89,19 @@ export function ReasoningEffortPicker({ ); diff --git a/app/src/features/conversations/aui/ThreadWorkspaceChip.tsx b/app/src/features/conversations/aui/ThreadWorkspaceChip.tsx index 7e4088221d4..42506028719 100644 --- a/app/src/features/conversations/aui/ThreadWorkspaceChip.tsx +++ b/app/src/features/conversations/aui/ThreadWorkspaceChip.tsx @@ -9,9 +9,9 @@ import { useEffect, useMemo, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { updateThreadWorkingDir } from '../../../store/threadSlice'; +import { isTauri } from '../../../utils/tauriCommands/common'; import { openhumanGetAgentPaths } from '../../../utils/tauriCommands/config'; import { pickDirectoryNatively } from '../../../utils/tauriCommands/directoryPicker'; -import { isTauri } from '../../../utils/tauriCommands/common'; import { recentWorkingFolders, WorkspacePicker } from './WorkspacePicker'; const debug = debugFactory('conversations:workspace'); diff --git a/app/src/features/conversations/aui/WorkspacePicker.tsx b/app/src/features/conversations/aui/WorkspacePicker.tsx index 344c86d507b..06696465ac6 100644 --- a/app/src/features/conversations/aui/WorkspacePicker.tsx +++ b/app/src/features/conversations/aui/WorkspacePicker.tsx @@ -8,13 +8,13 @@ * chip disappears with the empty state rather than offering a change the core * would refuse. */ -import { useT } from '../../../lib/i18n/I18nContext'; import { DropdownMenuContent, DropdownMenuItem, DropdownMenuRoot, DropdownMenuTrigger, } from '../../../components/ui/DropdownMenu'; +import { useT } from '../../../lib/i18n/I18nContext'; import { folderBasename } from '../threadList/groupThreads'; /** How many recently used folders the menu offers. */ @@ -113,7 +113,12 @@ export function WorkspacePicker({ stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> - + @@ -164,9 +169,7 @@ export function WorkspacePicker({ {error} ) : ( - - {t('composer.workspace.hint')} - + {t('composer.workspace.hint')} )}
); From 2e8f06733780abd2cec9192f56f78513ee898dbe Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:35:13 +0300 Subject: [PATCH 032/126] feat(conversations): add thread workspace chip Add a chip component that surfaces the active thread's workspace so users can see which workspace a conversation is bound to without leaving the thread view. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/aui/ThreadWorkspaceChip.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/app/src/features/conversations/aui/ThreadWorkspaceChip.tsx b/app/src/features/conversations/aui/ThreadWorkspaceChip.tsx index 42506028719..82f6ce7265b 100644 --- a/app/src/features/conversations/aui/ThreadWorkspaceChip.tsx +++ b/app/src/features/conversations/aui/ThreadWorkspaceChip.tsx @@ -23,7 +23,8 @@ export function ThreadWorkspaceChip({ threadId }: { threadId: string | null }) { const thread = threadId ? threads.find(candidate => candidate.id === threadId) : undefined; const [defaultDir, setDefaultDir] = useState(null); const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); + // Keyed by thread so a failure on one thread never shows on the next. + const [failure, setFailure] = useState<{ threadId: string; message: string } | null>(null); useEffect(() => { let cancelled = false; @@ -39,9 +40,6 @@ export function ThreadWorkspaceChip({ threadId }: { threadId: string | null }) { }; }, []); - // A new thread starts with no error from the previous one. - useEffect(() => setError(null), [threadId]); - const value = thread?.actionDir ?? null; const recent = useMemo( () => recentWorkingFolders(threads, [value, defaultDir]), @@ -49,17 +47,21 @@ export function ThreadWorkspaceChip({ threadId }: { threadId: string | null }) { ); if (!thread || thread.messageCount > 0) return null; + const error = failure?.threadId === thread.id ? failure.message : null; const bind = (dir: string | null) => { if (dir === value) return; setSaving(true); - setError(null); + setFailure(null); debug('[chat][workspace] bind thread=%s default=%s', thread.id, dir === null); void dispatch(updateThreadWorkingDir({ threadId: thread.id, actionDir: dir })) .unwrap() .catch((err: unknown) => { debug('[chat][workspace] bind failed thread=%s', thread.id); - setError(typeof err === 'string' && err ? err : t('composer.workspace.error')); + setFailure({ + threadId: thread.id, + message: typeof err === 'string' && err ? err : t('composer.workspace.error'), + }); }) .finally(() => setSaving(false)); }; @@ -69,7 +71,7 @@ export function ThreadWorkspaceChip({ threadId }: { threadId: string | null }) { if (picked.ok) { bind(picked.path); } else if (picked.reason === 'failed') { - setError(t('composer.workspace.error')); + setFailure({ threadId: thread.id, message: t('composer.workspace.error') }); } }; From 1900fd58b97cdb0fdca9299c21c65ee8b7be9726 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:35:53 +0300 Subject: [PATCH 033/126] test(conversations): add WorkspacePicker tests Cover the workspace picker's rendering and selection behaviour so the component's interactions are verified. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../aui/WorkspacePicker.test.tsx | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 app/src/features/conversations/aui/WorkspacePicker.test.tsx diff --git a/app/src/features/conversations/aui/WorkspacePicker.test.tsx b/app/src/features/conversations/aui/WorkspacePicker.test.tsx new file mode 100644 index 00000000000..751589a14b7 --- /dev/null +++ b/app/src/features/conversations/aui/WorkspacePicker.test.tsx @@ -0,0 +1,96 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { RECENT_FOLDER_LIMIT, recentWorkingFolders, WorkspacePicker } from './WorkspacePicker'; + +vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) })); + +function at(day: number) { + return new Date(2026, 9, day, 12).toISOString(); +} + +describe('recentWorkingFolders', () => { + it('lists distinct folders newest first, skipping the excluded ones', () => { + const threads = [ + { actionDir: '/p/a', lastMessageAt: at(1) }, + { actionDir: '/p/b', lastMessageAt: at(3) }, + { actionDir: '/p/a', lastMessageAt: at(4) }, + { actionDir: null, lastMessageAt: at(5) }, + { actionDir: '/p/default', lastMessageAt: at(6) }, + ]; + expect(recentWorkingFolders(threads, ['/p/default', null])).toEqual(['/p/a', '/p/b']); + }); + + it('caps the list', () => { + const threads = Array.from({ length: RECENT_FOLDER_LIMIT + 3 }, (_, i) => ({ + actionDir: `/p/${i}`, + lastMessageAt: at(i + 1), + })); + expect(recentWorkingFolders(threads, [])).toHaveLength(RECENT_FOLDER_LIMIT); + }); +}); + +describe('WorkspacePicker', () => { + it('labels the default folder by name when no folder is bound', () => { + render( + + ); + expect(screen.getByTestId('composer-workspace-label')).toHaveTextContent( + 'composer.workspace.defaultWithName' + ); + }); + + it('shows the bound folder basename and the error when one is passed', () => { + render( + + ); + expect(screen.getByTestId('composer-workspace-label')).toHaveTextContent('site'); + expect(screen.getByRole('alert')).toHaveTextContent('nope'); + }); + + it('binds a recent folder, the default, or opens the chooser', () => { + const onChange = vi.fn(); + const onChooseFolder = vi.fn(); + render( + + ); + const open = () => + fireEvent.pointerDown(screen.getByRole('button', { name: 'composer.workspace.label' }), { + button: 0, + ctrlKey: false, + }); + + open(); + fireEvent.click(screen.getByTestId('composer-workspace-recent')); + expect(onChange).toHaveBeenLastCalledWith('/home/me/work/api'); + + open(); + fireEvent.click(screen.getByTestId('composer-workspace-default')); + expect(onChange).toHaveBeenLastCalledWith(null); + + open(); + fireEvent.click(screen.getByTestId('composer-workspace-choose')); + expect(onChooseFolder).toHaveBeenCalledTimes(1); + }); + + it('hides the chooser where no host can open one', () => { + render(); + fireEvent.pointerDown(screen.getByRole('button', { name: 'composer.workspace.label' }), { + button: 0, + ctrlKey: false, + }); + expect(screen.queryByTestId('composer-workspace-choose')).toBeNull(); + }); +}); From 78d4d9500954a407679db8c5235b561fa3f640f9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:37:48 +0300 Subject: [PATCH 034/126] test(conversations): cover rendering of the conversations page Adds a render test for the conversations page to verify the list renders its items as expected. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../__tests__/Conversations.render.test.tsx | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index 9c45d996cee..35eefffcaac 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -851,6 +851,41 @@ describe('Conversations — smoke render (#1123 welcome-lock removal)', () => { expect(mockUpdateRuntimeSettings).toHaveBeenLastCalledWith({ reasoning_effort: '' }); }); + it('remembers the thinking level per model and sends that model its own level', async () => { + mockGetClientConfig.mockResolvedValue({ + result: { + reasoning_effort: 'low', + default_model: 'deep-model', + reasoning_effort_by_model: { 'deep-model': 'xhigh' }, + }, + }); + const { textarea, thread } = await renderSelectedConversation(); + + const picker = (await screen.findByTestId('composer-reasoning-effort')) as HTMLSelectElement; + await waitFor(() => expect(picker.value).toBe('xhigh')); + + fireEvent.change(picker, { target: { value: 'medium' } }); + expect(mockUpdateRuntimeSettings).toHaveBeenCalledWith({ + reasoning_effort: 'medium', + reasoning_effort_model: 'deep-model', + }); + + await submitComposerText(textarea, 'think a bit'); + await waitFor(() => { + expect(chatSend).toHaveBeenCalledWith( + expect.objectContaining({ threadId: thread.id, reasoningEffort: 'medium' }) + ); + }); + + // Dropping the model's own level falls back to the global one. + fireEvent.change(picker, { target: { value: 'default' } }); + expect(mockUpdateRuntimeSettings).toHaveBeenLastCalledWith({ + reasoning_effort: '', + reasoning_effort_model: 'deep-model', + }); + await waitFor(() => expect(picker.value).toBe('low')); + }); + it('persists a local user message and sends through chat service for valid input', async () => { const { textarea, thread } = await renderSelectedConversation(); From 2c4d9a57934f4034e12db49be5e6be7fb94abe2b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:38:22 +0300 Subject: [PATCH 035/126] test(conversations): cover thread workspace chip rendering Adds a test file for the thread workspace chip, exercising its rendering and interaction paths so the component's behaviour is pinned down. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../aui/ThreadWorkspaceChip.test.tsx | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 app/src/features/conversations/aui/ThreadWorkspaceChip.test.tsx diff --git a/app/src/features/conversations/aui/ThreadWorkspaceChip.test.tsx b/app/src/features/conversations/aui/ThreadWorkspaceChip.test.tsx new file mode 100644 index 00000000000..158c35b0eb7 --- /dev/null +++ b/app/src/features/conversations/aui/ThreadWorkspaceChip.test.tsx @@ -0,0 +1,100 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { threadApi } from '../../../services/api/threadApi'; +import { createTestStore, renderWithProviders } from '../../../test/test-utils'; +import type { Thread } from '../../../types/thread'; +import { ThreadWorkspaceChip } from './ThreadWorkspaceChip'; + +vi.mock('../../../lib/i18n/I18nContext', async importOriginal => ({ + ...(await importOriginal()), + useT: () => ({ t: (key: string) => key }), +})); +vi.mock('../../../utils/tauriCommands/config', async importOriginal => ({ + ...(await importOriginal()), + openhumanGetAgentPaths: vi + .fn() + .mockResolvedValue({ result: { action_dir: '/home/me/projects' } }), +})); +vi.mock('../../../utils/tauriCommands/common', async importOriginal => ({ + ...(await importOriginal()), + isTauri: () => true, +})); +const pickDirectoryNatively = vi.fn(); +vi.mock('../../../utils/tauriCommands/directoryPicker', () => ({ + pickDirectoryNatively: () => pickDirectoryNatively(), +})); + +function thread(id: string, extra: Partial = {}): Thread { + return { + id, + title: 'Chat', + chatId: null, + isActive: true, + messageCount: 0, + lastMessageAt: new Date(2026, 9, 6).toISOString(), + createdAt: new Date(2026, 9, 6).toISOString(), + labels: [], + ...extra, + }; +} + +function renderChip(threads: Thread[], threadId: string | null) { + const base = createTestStore().getState() as { thread: Record }; + const store = createTestStore({ + thread: { ...base.thread, threads, selectedThreadId: threadId }, + }); + renderWithProviders(, { store }); + return store; +} + +function openMenu() { + fireEvent.pointerDown(screen.getByRole('button', { name: 'composer.workspace.label' }), { + button: 0, + ctrlKey: false, + }); +} + +describe('ThreadWorkspaceChip', () => { + beforeEach(() => { + vi.restoreAllMocks(); + pickDirectoryNatively.mockReset(); + }); + + it('renders nothing once the thread has messages', () => { + renderChip([thread('t1', { messageCount: 2 })], 't1'); + expect(screen.queryByTestId('composer-workspace')).toBeNull(); + }); + + it('binds a chosen folder on the empty thread', async () => { + pickDirectoryNatively.mockResolvedValue({ ok: true, path: '/home/me/work/api' }); + const update = vi + .spyOn(threadApi, 'updateWorkingDir') + .mockResolvedValue(thread('t1', { actionDir: '/home/me/work/api' })); + renderChip([thread('t1')], 't1'); + + openMenu(); + fireEvent.click(screen.getByTestId('composer-workspace-choose')); + + await waitFor(() => expect(update).toHaveBeenCalledWith('t1', '/home/me/work/api')); + await waitFor(() => + expect(screen.getByTestId('composer-workspace-label')).toHaveTextContent('api') + ); + }); + + it('shows the core refusal when a folder cannot be bound', async () => { + vi.spyOn(threadApi, 'updateWorkingDir').mockRejectedValue( + new Error('working folder is in a protected location') + ); + renderChip([thread('t1'), thread('t0', { actionDir: '/home/me/.ssh-ish' })], 't1'); + + openMenu(); + fireEvent.click(screen.getByTestId('composer-workspace-recent')); + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent( + 'working folder is in a protected location' + ) + ); + }); +}); From 607681b7fc48bb7f6407cd39ccb676345ebacee4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:39:12 +0300 Subject: [PATCH 036/126] test(json-rpc): cover working dir binding before first message Add an end-to-end test for the thread working directory lifecycle: a folder set at creation is carried on the thread, clearing it on an empty thread falls back to the default, protected folders are refused, and once the conversation has a message the folder can no longer be changed. Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/json_rpc_e2e.rs | 112 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index c902c90affa..1314881d799 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -2771,6 +2771,118 @@ async fn json_rpc_thread_title_create_and_update() { rpc_join.abort(); } +#[tokio::test] +async fn json_rpc_thread_working_dir_binds_before_the_first_message() { + let _env_lock = json_rpc_e2e_env_lock_async().await; + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + let project = home.join("projects").join("site"); + std::fs::create_dir_all(&project).expect("project dir"); + let project = project.canonicalize().expect("canonical project"); + let project_str = project.to_string_lossy().into_owned(); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_url_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + let _api_url_guard = EnvVarGuard::unset("OPENHUMAN_API_URL"); + + let (api_addr, api_join) = serve_on_ephemeral(mock_upstream_router()).await; + let api_origin = format!("http://{api_addr}"); + write_min_config(openhuman_home.as_path(), &api_origin); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{rpc_addr}"); + + // 1. A thread created in a folder carries it. + let create = post_json_rpc( + &rpc_base, + 9201, + "openhuman.threads_create_new", + json!({ "action_dir": project_str }), + ) + .await; + let created = assert_no_jsonrpc_error(&create, "threads_create_new with action_dir") + .get("data") + .cloned() + .expect("data envelope"); + let thread_id = created + .get("id") + .and_then(Value::as_str) + .expect("thread id") + .to_string(); + assert_eq!( + created.get("actionDir").and_then(Value::as_str), + Some(project_str.as_str()) + ); + + // 2. Clearing it on the still-empty thread goes back to the default. + let cleared = post_json_rpc( + &rpc_base, + 9202, + "openhuman.threads_update_working_dir", + json!({ "thread_id": thread_id, "action_dir": "" }), + ) + .await; + let cleared = assert_no_jsonrpc_error(&cleared, "threads_update_working_dir clear"); + assert!(cleared + .get("data") + .and_then(|d| d.get("actionDir")) + .is_none()); + + // 3. A protected folder is refused. + let ssh = home.join(".ssh"); + std::fs::create_dir_all(&ssh).expect("ssh dir"); + let refused = post_json_rpc( + &rpc_base, + 9203, + "openhuman.threads_update_working_dir", + json!({ "thread_id": thread_id, "action_dir": ssh.to_string_lossy() }), + ) + .await; + assert_jsonrpc_error(&refused, "threads_update_working_dir protected folder"); + + // 4. Once the conversation has a message, the folder is fixed. + let append = post_json_rpc( + &rpc_base, + 9204, + "openhuman.threads_message_append", + json!({ + "thread_id": thread_id, + "message": { + "id": "m1", + "content": "hello", + "type": "text", + "extraMetadata": {}, + "sender": "user", + "createdAt": "2026-10-06T12:00:00Z" + } + }), + ) + .await; + assert_no_jsonrpc_error(&append, "threads_message_append"); + let late = post_json_rpc( + &rpc_base, + 9205, + "openhuman.threads_update_working_dir", + json!({ "thread_id": thread_id, "action_dir": project_str }), + ) + .await; + let late_err = assert_jsonrpc_error(&late, "threads_update_working_dir after a message"); + let message = late_err + .get("message") + .and_then(Value::as_str) + .unwrap_or_default(); + assert!( + message.contains("before the conversation's first message"), + "expected started-thread refusal, got: {message}" + ); + + api_join.abort(); + rpc_join.abort(); +} + #[tokio::test] async fn json_rpc_thread_not_found_errors_are_structured() { let _env_lock = json_rpc_e2e_env_lock_async().await; From 7cfe3417885809e06cb108ab6977c4e0f5e08448 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 6 Oct 2026 22:40:00 +0300 Subject: [PATCH 037/126] feat(about_app): add conversation intelligence catalog entry Adds a catalog entry describing the conversation intelligence capability so it appears in the about app listing alongside the other platform features. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../about_app/catalog_conversation_intelligence.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/openhuman-core/src/platform/about_app/catalog_conversation_intelligence.rs b/crates/openhuman-core/src/platform/about_app/catalog_conversation_intelligence.rs index 2f9c9c8c3b3..4352871f707 100644 --- a/crates/openhuman-core/src/platform/about_app/catalog_conversation_intelligence.rs +++ b/crates/openhuman-core/src/platform/about_app/catalog_conversation_intelligence.rs @@ -192,11 +192,21 @@ Capability { name: "Thinking Level", domain: "conversation", category: CapabilityCategory::Conversation, - description: "Choose how hard the model thinks before answering: Auto (the provider's default), Off, Low, Medium, High or Max. The choice applies to the conversation's own turns, is remembered as the default for new ones, and is translated into each provider's reasoning setting; delegated sub-agents keep their provider default.", + description: "Choose how hard the model thinks before answering: Auto (the provider's default), Off, Minimal, Low, Medium, High or Max. Every model remembers its own level, so switching models brings back the level last used with that one; a model with no level of its own uses the global default. The choice applies to the conversation's own turns and is translated into each provider's reasoning setting; delegated sub-agents keep their provider default.", how_to: "Conversations > pick a thinking level beside the model selector in the composer", status: CapabilityStatus::Beta, privacy: None, }, +Capability { + id: "conversation.working_folder", + name: "Conversation Working Folder", + domain: "conversation", + category: CapabilityCategory::Conversation, + description: "Start a conversation in a folder of your choice: the agent's shell, file and git tools act there instead of the default projects folder. The folder is picked before the first message and stays fixed for the conversation; credential stores and system folders cannot be chosen.", + how_to: "Conversations > New conversation > pick a folder in the chip above the composer", + status: CapabilityStatus::Beta, + privacy: None, + }, Capability { id: "conversation.subagent_mascots", name: "Subagent Mascots", From 84d3e79ecdc1f9ce78913a76b51755c0cff9c249 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:19:51 +0300 Subject: [PATCH 038/126] feat(assistant-ui): add todo list element Add a todo-list element for the assistant UI that renders a checklist of items with their completion state. This gives assistant responses a dedicated way to present task progress. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../assistant-ui/elements/todo-list.tsx | 263 +++++++++++++++--- 1 file changed, 224 insertions(+), 39 deletions(-) diff --git a/app/src/components/assistant-ui/elements/todo-list.tsx b/app/src/components/assistant-ui/elements/todo-list.tsx index c1acc310f71..f4857a3df33 100644 --- a/app/src/components/assistant-ui/elements/todo-list.tsx +++ b/app/src/components/assistant-ui/elements/todo-list.tsx @@ -12,10 +12,14 @@ * so the caller (`TodoListPart` in * `features/conversations/aui/TodoListPart.tsx`) supplies the translated * string via `useT()`. + * - OpenHuman additions after OpenClaw's session progress card: + * {@link todoProgress}, the collapsible {@link TodoProgressCard} pinned above + * the composer, and the one-line {@link TodoReceipt} a `todo` tool call + * leaves in the transcript. Every string they show is a prop. */ import { cn } from '@/components/assistant-ui/lib/utils'; -import { CheckIcon, Loader2Icon, XIcon } from 'lucide-react'; -import type { ComponentProps } from 'react'; +import { CheckIcon, ChevronRightIcon, ListChecksIcon, Loader2Icon, XIcon } from 'lucide-react'; +import type { ComponentProps, ReactNode } from 'react'; import { mono } from './surfaces'; @@ -54,44 +58,225 @@ export function TodoList({ : `${done}/${items.length} · rev ${revision}`} -
    - {items.map(item => ( -
  • - - {item.status === 'done' ? ( - - - - ) : item.status === 'failed' ? ( - - - - ) : item.status === 'active' ? ( - - ) : ( - - )} - - {item.status} -
    - - {item.text} + +
    + ); +} + +/** The step rows, shared by every todo surface. */ +export function TodoItems({ items }: { items: readonly TodoItem[] }) { + return ( +
      + {items.map(item => ( +
    • + + {item.status === 'done' ? ( + + - {item.status === 'failed' && item.reason ? ( -

      {item.reason}

      - ) : null} - -
    • - ))} -
    + ) : item.status === 'failed' ? ( + + + + ) : item.status === 'active' ? ( + + ) : ( + + )} + + {item.status} +
    + + {item.text} + + {item.status === 'failed' && item.reason ? ( +

    {item.reason}

    + ) : null} +
    +
  • + ))} +
+ ); +} + +export interface TodoProgress { + done: number; + total: number; + /** The step to name when collapsed: first active, else first pending, else last done. */ + current: TodoItem | undefined; + /** 1-based position of {@link TodoProgress.current}, or 0 when there is none. */ + position: number; + allDone: boolean; + anyActive: boolean; +} + +/** Counts and the "current step" a collapsed progress line shows. */ +export function todoProgress(items: readonly TodoItem[]): TodoProgress { + const done = items.filter(item => item.status === 'done').length; + let index = items.findIndex(item => item.status === 'active'); + if (index < 0) index = items.findIndex(item => item.status === 'pending'); + if (index < 0) { + for (let i = items.length - 1; i >= 0; i -= 1) { + if (items[i].status === 'done') { + index = i; + break; + } + } + } + return { + done, + total: items.length, + current: index >= 0 ? items[index] : undefined, + position: index + 1, + allDone: items.length > 0 && done === items.length, + anyActive: items.some(item => item.status === 'active'), + }; +} + +function ProgressMarker({ progress }: { progress: TodoProgress }) { + if (progress.allDone) { + return ; + } + if (progress.anyActive) { + return ( + + ); + } + return ; +} + +/** + * The run's step list pinned above the composer, as a disclosure. Collapsed it + * is one line — marker, the current step, `pos/total` (or the completed + * label) — so a long plan never pushes the input off screen; expanded it + * shows `{done} of {total}` and every step, capped in height and scrolling. + */ +export function TodoProgressCard({ + items, + open, + onOpenChange, + title, + completedLabel, + countLabel, + className, + ...props +}: Omit, 'children' | 'title'> & { + items: readonly TodoItem[]; + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + /** Shown instead of `pos/total` once every step is done. */ + completedLabel: string; + /** `{done} of {total}` in the expanded header, already interpolated. */ + countLabel: string; +}) { + const progress = todoProgress(items); + return ( +
+ + {open && ( +
+ +
+ )} +
+ ); +} + +/** + * What a `todo` tool call leaves in the transcript: one line ("Progress + * updated — 3/7 · current step") that expands to that call's snapshot, so a + * long run does not repeat the full list after every update. + */ +export function TodoReceipt({ + items, + label, + open, + onOpenChange, + extra, +}: { + items: readonly TodoItem[]; + /** The line's text, already interpolated with the counts. */ + label: string; + open: boolean; + onOpenChange: (open: boolean) => void; + extra?: ReactNode; +}) { + const progress = todoProgress(items); + return ( +
+ + {open && ( +
+ +
+ )} + {extra}
); } From 326747f7eafbc254f4513f36d19d583949981058 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:19:57 +0300 Subject: [PATCH 039/126] feat(conversations): show agent running status in aui Adds an AgentRunningStatus component that renders the current agent activity so users can see when a conversation is still being worked on. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/aui/AgentRunningStatus.tsx | 142 +++++++++++++++++- 1 file changed, 135 insertions(+), 7 deletions(-) diff --git a/app/src/features/conversations/aui/AgentRunningStatus.tsx b/app/src/features/conversations/aui/AgentRunningStatus.tsx index da0c305428f..bc71e1cffa0 100644 --- a/app/src/features/conversations/aui/AgentRunningStatus.tsx +++ b/app/src/features/conversations/aui/AgentRunningStatus.tsx @@ -15,10 +15,20 @@ * component at all; assistant-ui's own running-message indicator already * signals "something is happening" for those, same as before. * - * With no task running, this falls back to assistant-ui's vendored - * `GenerationLoader`, with a shimmering Thinking label, so a turn that has not - * yet spawned a sub-agent still shows one coherent running signal. + * Three states, highest priority first (after OpenClaw's working row): + * + * 1. **Waiting on you.** A parked tool approval, a plan review gate, or an + * unanswered top-level `ask_user_clarification` means the run cannot move + * until the user acts. Saying "Thinking…" there is wrong, so the slot shows + * a still (non-shimmering) attention line naming what it waits for. + * 2. **Waiting on sub-agents.** While delegations run, the `TaskTray`, led by + * "Waiting on {name}" / "Waiting on {count} subagents" when the parent + * itself is parked on them (`inferenceStatusByThread.phase === 'subagent'`). + * 3. **Thinking.** The vendored `GenerationLoader`, with "Thinking · {elapsed}" + * once a second has passed. The clock starts when this slot mounts, which is + * when the turn starts running: the runtime keeps no turn-start timestamp. */ +import { useAuiState } from '@assistant-ui/react'; import { useEffect, useState } from 'react'; import { @@ -26,7 +36,10 @@ import { useTaskSummary, } from '../../../components/assistant-ui/elements/agent-status.aui'; import { GenerationLoader } from '../../../components/assistant-ui/elements/loading-state'; +import { formatElapsed, useTaskElapsed } from '../../../components/assistant-ui/utils/task'; import { useT } from '../../../lib/i18n/I18nContext'; +import { useAuiThreadId } from '../../../providers/AssistantUiRuntimeProvider'; +import { useAppSelector } from '../../../store/hooks'; /** English defaults mapped onto `AgentStatusStrings` via `useT()`. */ function useAgentStatusStrings() { @@ -42,30 +55,145 @@ function useAgentStatusStrings() { }; } +/** What the run is blocked on, if it is blocked on the user. */ +export type WaitingOnUser = 'approval' | 'review' | 'answer'; + +/** The top-level clarification tool (`ChatToolParts.tsx`'s `ElicitationCall`). */ +const ASK_USER_CLARIFICATION_TOOL = 'ask_user_clarification'; + +interface MessageLike { + readonly role: string; + readonly content: ReadonlyArray<{ + readonly type: string; + readonly toolName?: string; + readonly result?: unknown; + }>; +} + +/** Whether the newest assistant message carries an unanswered clarification. */ +export function hasPendingClarification(messages: ReadonlyArray): boolean { + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message.role !== 'assistant') continue; + return message.content.some( + part => + part.type === 'tool-call' && + part.toolName === ASK_USER_CLARIFICATION_TOOL && + part.result === undefined + ); + } + return false; +} + +/** Approval outranks review outranks answer: the most blocking gate first. */ +export function waitingOnUser(args: { + approval: boolean; + review: boolean; + answer: boolean; +}): WaitingOnUser | null { + if (args.approval) return 'approval'; + if (args.review) return 'review'; + if (args.answer) return 'answer'; + return null; +} + +const WAITING_KEYS: Record = { + approval: 'chat.status.waitingApproval', + review: 'chat.status.waitingReview', + answer: 'chat.status.waitingAnswer', +}; + +function useWaitingOnUser(): WaitingOnUser | null { + const threadId = useAuiThreadId(); + const approval = useAppSelector(state => + threadId ? Boolean(state.chatRuntime.pendingApprovalByThread?.[threadId]) : false + ); + const review = useAppSelector(state => + threadId ? Boolean(state.chatRuntime.pendingPlanReviewByThread?.[threadId]) : false + ); + const answer = useAuiState(s => hasPendingClarification(s.thread.messages)); + return waitingOnUser({ approval, review, answer }); +} + +/** Whether the parent turn is itself parked waiting on its delegations. */ +function useParentWaitsOnSubagents(): boolean { + const threadId = useAuiThreadId(); + return useAppSelector(state => + threadId ? state.chatRuntime.inferenceStatusByThread?.[threadId]?.phase === 'subagent' : false + ); +} + +function WaitingLine({ kind }: { kind: WaitingOnUser }) { + const { t } = useT(); + return ( +
+ + + + + {t(WAITING_KEYS[kind])} +
+ ); +} + export function AgentRunningStatus() { const { t } = useT(); const summary = useTaskSummary(); const strings = useAgentStatusStrings(); + const waiting = useWaitingOnUser(); + const parentWaits = useParentWaitsOnSubagents(); const [tick, setTick] = useState(0); + const [startedAt] = useState(() => Date.now()); + const thinking = summary.total === 0 && waiting === null; + const elapsedMs = useTaskElapsed({ startedAt }, thinking); useEffect(() => { - if (summary.total > 0) return undefined; + if (!thinking) return undefined; const timer = window.setInterval(() => setTick(value => value + 1), 120); return () => window.clearInterval(timer); - }, [summary.total]); + }, [thinking]); + + if (waiting !== null) return ; if (summary.total === 0) { + const label = + elapsedMs !== undefined && elapsedMs >= 1000 + ? t('chat.status.thinkingElapsed').replace('{elapsed}', formatElapsed(elapsedMs)) + : t('chat.thinkingDots'); return ( ); } - return ; + + const waitingOnSubagents = + parentWaits && summary.running > 0 + ? summary.running === 1 && summary.runningLabel + ? t('chat.status.waitingOnSubagent').replace('{name}', summary.runningLabel) + : t('chat.status.waitingOnSubagents').replace('{count}', String(summary.running)) + : null; + + return ( +
+ {waitingOnSubagents && ( + + {waitingOnSubagents} + + )} + +
+ ); } export default AgentRunningStatus; From 8e3e347c7797e4fc494024c2aafca75d8a566848 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:20:05 +0300 Subject: [PATCH 040/126] feat(conversations): add pinned todo card Introduce a card component that surfaces a pinned todo within the conversations view so users can see the active item at a glance. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/aui/PinnedTodoCard.tsx | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 app/src/features/conversations/aui/PinnedTodoCard.tsx diff --git a/app/src/features/conversations/aui/PinnedTodoCard.tsx b/app/src/features/conversations/aui/PinnedTodoCard.tsx new file mode 100644 index 00000000000..35871181a59 --- /dev/null +++ b/app/src/features/conversations/aui/PinnedTodoCard.tsx @@ -0,0 +1,110 @@ +/** + * The thread's live todo list pinned above the composer, as OpenClaw's + * collapsible session progress card (`TodoProgressCard`). + * + * Open by default while a step is in progress and collapsed otherwise, until + * the user opens or closes it by hand: that choice is remembered per thread in + * `userScopedStorage` and wins from then on. + */ +import debugFactory from 'debug'; +import { useCallback, useEffect, useState } from 'react'; + +import { + type TodoItem, + TodoProgressCard, + todoProgress, +} from '../../../components/assistant-ui/elements/todo-list'; +import { useT } from '../../../lib/i18n/I18nContext'; +import { userScopedStorage } from '../../../store/userScopedStorage'; + +const debug = debugFactory('conversations:todos'); + +/** `userScopedStorage` key holding `{ [threadId]: open }`. */ +export const TODO_CARD_OPEN_KEY = 'chat.todoCardOpen'; +/** How many threads' choices are kept; the oldest are dropped first. */ +const MAX_REMEMBERED = 50; + +type OpenMap = Record; + +async function readOpenMap(): Promise { + try { + const raw = await userScopedStorage.getItem(TODO_CARD_OPEN_KEY); + const parsed: unknown = raw ? JSON.parse(raw) : {}; + return parsed && typeof parsed === 'object' ? (parsed as OpenMap) : {}; + } catch { + return {}; + } +} + +async function rememberOpen(threadId: string, open: boolean): Promise { + const map = await readOpenMap(); + delete map[threadId]; + map[threadId] = open; + const keys = Object.keys(map); + for (const stale of keys.slice(0, Math.max(0, keys.length - MAX_REMEMBERED))) { + delete map[stale]; + } + await userScopedStorage.setItem(TODO_CARD_OPEN_KEY, JSON.stringify(map)); +} + +/** The card's open state: the user's remembered choice, else open while a step runs. */ +export function useTodoCardOpen( + threadId: string, + anyActive: boolean +): [boolean, (open: boolean) => void] { + const [remembered, setRemembered] = useState<{ threadId: string; open: boolean } | null>(null); + + useEffect(() => { + let cancelled = false; + void readOpenMap().then(map => { + if (cancelled) return; + const open = map[threadId]; + setRemembered(typeof open === 'boolean' ? { threadId, open } : null); + }); + return () => { + cancelled = true; + }; + }, [threadId]); + + const setOpen = useCallback( + (open: boolean) => { + setRemembered({ threadId, open }); + debug('todo card %s thread=%s', open ? 'opened' : 'closed', threadId); + void rememberOpen(threadId, open); + }, + [threadId] + ); + + const choice = remembered?.threadId === threadId ? remembered.open : undefined; + return [choice ?? anyActive, setOpen]; +} + +export function PinnedTodoCard({ + threadId, + items, + className, +}: { + threadId: string; + items: readonly TodoItem[]; + className?: string; +}) { + const { t } = useT(); + const progress = todoProgress(items); + const [open, setOpen] = useTodoCardOpen(threadId, progress.anyActive); + return ( + + ); +} + +export default PinnedTodoCard; From 798c3e098720f3b5be2466c1c85ff6853f9dd192 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:20:16 +0300 Subject: [PATCH 041/126] feat(conversations): add conversation list view Adds a conversations feature component that renders the list of conversations for the current user. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/features/conversations/Conversations.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 2bf43932add..3cf08f3c9c4 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -1706,10 +1706,9 @@ const Conversations = ({ /> )} {selectedThreadId && liveTodos && liveTodos.length > 0 && ( - )} From 8330330dba45fb2c7086ac4cb9ecc54421d0f640 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:20:18 +0300 Subject: [PATCH 042/126] feat(conversations): add thread draft hook Add a hook that keeps per-thread draft text so switching between conversations no longer discards what the user had typed. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/hooks/useThreadDraft.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 app/src/features/conversations/hooks/useThreadDraft.ts diff --git a/app/src/features/conversations/hooks/useThreadDraft.ts b/app/src/features/conversations/hooks/useThreadDraft.ts new file mode 100644 index 00000000000..a91039d0476 --- /dev/null +++ b/app/src/features/conversations/hooks/useThreadDraft.ts @@ -0,0 +1,109 @@ +/** + * The composer draft, kept per thread and across reloads (after OpenClaw's + * per-session composer persistence). + * + * Switching threads swaps the draft instead of carrying the half-typed text + * into the next conversation, and the text survives a reload. Persistence goes + * through `userScopedStorage`, so one account never sees another's drafts, and + * is debounced so a keystroke burst costs one write. An empty draft removes + * its key, which is how a send clears it. + */ +import debugFactory from 'debug'; +import { type SetStateAction, useCallback, useEffect, useRef, useState } from 'react'; + +import { userScopedStorage } from '../../../store/userScopedStorage'; + +const debug = debugFactory('conversations:draft'); + +/** Debounce between the last keystroke and the storage write. */ +export const DRAFT_SAVE_DEBOUNCE_MS = 200; + +export function draftStorageKey(threadId: string): string { + return `chat:draft:${threadId}`; +} + +function persistDraft(threadId: string, text: string): void { + const key = draftStorageKey(threadId); + // Lengths only — never the draft text itself. + debug('[chat][draft] persist thread=%s len=%d', threadId, text.length); + void (text.length > 0 ? userScopedStorage.setItem(key, text) : userScopedStorage.removeItem(key)); +} + +interface DraftState { + threadId: string | null; + text: string; +} + +/** + * `[draft, setDraft]` for `threadId`, a drop-in for `useState('')`: the setter + * accepts a value or an updater. + */ +export function useThreadDraft( + threadId: string | null +): [string, (action: SetStateAction) => void] { + const [state, setState] = useState({ threadId, text: '' }); + const threadIdRef = useRef(threadId); + threadIdRef.current = threadId; + // The newest edit not yet written, so a thread switch or unmount inside the + // debounce window still saves it. + const pendingRef = useRef(null); + const timerRef = useRef | null>(null); + + const flush = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + const pending = pendingRef.current; + pendingRef.current = null; + if (pending?.threadId) persistDraft(pending.threadId, pending.text); + }, []); + + // Load the stored draft for each thread the view switches to. + useEffect(() => { + flush(); + if (!threadId) return; + let cancelled = false; + void userScopedStorage.getItem(draftStorageKey(threadId)).then(stored => { + if (cancelled || !stored) return; + setState(prev => { + // Typing that landed before the read finished wins over the stored copy. + if (prev.threadId === threadId && prev.text.length > 0) return prev; + debug('[chat][draft] restored thread=%s len=%d', threadId, stored.length); + return { threadId, text: stored }; + }); + }); + return () => { + cancelled = true; + }; + }, [flush, threadId]); + + useEffect(() => flush, [flush]); + + const setDraft = useCallback( + (action: SetStateAction) => { + const current = threadIdRef.current; + setState(prev => { + const base = prev.threadId === current ? prev.text : ''; + const text = typeof action === 'function' ? action(base) : action; + return text === base && prev.threadId === current ? prev : { threadId: current, text }; + }); + }, + [] + ); + + // Schedule the write for whatever the draft became. + useEffect(() => { + if (!state.threadId || state.threadId !== threadIdRef.current) return; + pendingRef.current = state; + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + timerRef.current = null; + const pending = pendingRef.current; + pendingRef.current = null; + if (pending?.threadId) persistDraft(pending.threadId, pending.text); + }, DRAFT_SAVE_DEBOUNCE_MS); + }, [state]); + + return [state.threadId === threadId ? state.text : '', setDraft]; +} From 095dfd0d991bc4a2f1c53eed68d914fc63006cea Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:20:24 +0300 Subject: [PATCH 043/126] feat(conversations): add conversation list view Add a Conversations feature component that renders the list of conversations for the current user. This gives the app a dedicated place to browse and select conversations. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/features/conversations/Conversations.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 3cf08f3c9c4..6c85a75ad59 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -6,7 +6,6 @@ import { type ChatSendError, chatSendError } from '../../chat/chatSendError'; import { checkPromptInjection, promptGuardMessage } from '../../chat/promptInjectionGuard'; import { trackAnalyticsEvent } from '../../components/analytics'; import { AgentStatus } from '../../components/assistant-ui/elements/agent-status'; -import { TodoList } from '../../components/assistant-ui/elements/todo-list'; import ChatFilesChip from '../../components/chat/ChatFilesChip'; import WorkflowProposalCard from '../../components/chat/WorkflowProposalCard'; import { ConfirmationModal } from '../../components/intelligence/ConfirmationModal'; From e828d83c50c6a0c6b438f0500073c241b06f39dc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:20:26 +0300 Subject: [PATCH 044/126] feat(conversations): add conversation list view Adds a conversations feature component that renders the list of conversations for the current user. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/features/conversations/Conversations.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 6c85a75ad59..2e908bc0b9d 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -13,6 +13,7 @@ import { SidebarContent } from '../../components/layout/shell/SidebarSlot'; import { ArtifactCardAdapter } from '../../features/conversations/aui/ArtifactCardAdapter'; import { ContextUsage } from '../../features/conversations/aui/ContextUsage'; import { PlanReviewCardCore } from '../../features/conversations/aui/PlanReviewPart'; +import { PinnedTodoCard } from '../../features/conversations/aui/PinnedTodoCard'; import { toAuiTodoItems } from '../../features/conversations/aui/TodoListPart'; import { useRunMode } from '../../features/conversations/aui/useRunMode'; import { From 898c2c75230fb6ff78b14a3a7a6894eaf6b82085 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:20:30 +0300 Subject: [PATCH 045/126] feat(conversations): add thread draft hook Add a hook that manages draft state for a conversation thread so composers can persist and restore in-progress messages. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../features/conversations/hooks/useThreadDraft.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/features/conversations/hooks/useThreadDraft.ts b/app/src/features/conversations/hooks/useThreadDraft.ts index a91039d0476..dc872ca01ed 100644 --- a/app/src/features/conversations/hooks/useThreadDraft.ts +++ b/app/src/features/conversations/hooks/useThreadDraft.ts @@ -32,6 +32,8 @@ function persistDraft(threadId: string, text: string): void { interface DraftState { threadId: string | null; text: string; + /** Set by an edit; a load or the initial state never writes back. */ + edited: boolean; } /** @@ -41,7 +43,7 @@ interface DraftState { export function useThreadDraft( threadId: string | null ): [string, (action: SetStateAction) => void] { - const [state, setState] = useState({ threadId, text: '' }); + const [state, setState] = useState({ threadId, text: '', edited: false }); const threadIdRef = useRef(threadId); threadIdRef.current = threadId; // The newest edit not yet written, so a thread switch or unmount inside the @@ -70,7 +72,7 @@ export function useThreadDraft( // Typing that landed before the read finished wins over the stored copy. if (prev.threadId === threadId && prev.text.length > 0) return prev; debug('[chat][draft] restored thread=%s len=%d', threadId, stored.length); - return { threadId, text: stored }; + return { threadId, text: stored, edited: false }; }); }); return () => { @@ -86,7 +88,9 @@ export function useThreadDraft( setState(prev => { const base = prev.threadId === current ? prev.text : ''; const text = typeof action === 'function' ? action(base) : action; - return text === base && prev.threadId === current ? prev : { threadId: current, text }; + return text === base && prev.threadId === current + ? prev + : { threadId: current, text, edited: true }; }); }, [] @@ -94,7 +98,7 @@ export function useThreadDraft( // Schedule the write for whatever the draft became. useEffect(() => { - if (!state.threadId || state.threadId !== threadIdRef.current) return; + if (!state.edited || !state.threadId || state.threadId !== threadIdRef.current) return; pendingRef.current = state; if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => { From e060fcc75159ef22101b1f032b4cf155a4d817ee Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:20:37 +0300 Subject: [PATCH 046/126] feat(conversations): add todo list part component Add a TodoListPart component that renders a todo list within conversation messages, giving the assistant a way to present structured task items alongside regular message content. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/aui/TodoListPart.tsx | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/app/src/features/conversations/aui/TodoListPart.tsx b/app/src/features/conversations/aui/TodoListPart.tsx index c57361d46ac..04824bcaadf 100644 --- a/app/src/features/conversations/aui/TodoListPart.tsx +++ b/app/src/features/conversations/aui/TodoListPart.tsx @@ -2,9 +2,11 @@ import type { ToolCallMessagePartComponent } from '@assistant-ui/react'; import { type TodoItem, - TodoList, + TodoReceipt, type TodoStatus, + todoProgress, } from '../../../components/assistant-ui/elements/todo-list'; +import { useDisclosure } from '../../../components/assistant-ui/lib/useDisclosure'; import { useT } from '../../../lib/i18n/I18nContext'; import type { CoreTodoStatus } from '../../../store/threadTodosSlice'; @@ -61,17 +63,32 @@ interface TodoToolArgs { } /** - * Toolkit render for the `todo` tool call — a standalone element in the - * transcript showing the todo list snapshot as of THIS call (args carry the - * write while in flight; result echoes it back once settled). The pinned, - * always-current list above the composer is a separate render, driven by - * {@link useThreadTodos} off the live `thread_todos_changed` event, not this - * per-call snapshot. + * Toolkit render for the `todo` tool call — a one-line receipt in the + * transcript ("Progress updated — 3/7 · current step") that expands to the + * todo list snapshot as of THIS call (args carry the write while in flight; + * result echoes it back once settled). A run updates its list many times, and + * repeating the whole list after every update buried the conversation. The + * pinned, always-current list above the composer is a separate render + * (`PinnedTodoCard`), driven by {@link useThreadTodos} off the live + * `thread_todos_changed` event, not this per-call snapshot. */ -export const TodoListPart: ToolCallMessagePartComponent = ({ args, result }) => { +export const TodoListPart: ToolCallMessagePartComponent = ({ args, result, toolCallId }) => { const { t } = useT(); + const [open, setOpen] = useDisclosure(toolCallId ? `todo:${toolCallId}` : undefined, false); const payload = (result ?? args) as TodoToolArgs | undefined; const items = toAuiTodoItems(payload?.todos); if (items.length === 0) return null; - return ; + const progress = todoProgress(items); + return ( +
+ +
+ ); }; From ec0eef7ad7a13acd0a0f2e2417188dcdd6ce896c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:20:44 +0300 Subject: [PATCH 047/126] test(conversations): cover thread draft hook behaviour Add tests for the thread draft hook, exercising draft persistence and restoration so regressions in draft handling are caught. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../hooks/useThreadDraft.test.ts | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 app/src/features/conversations/hooks/useThreadDraft.test.ts diff --git a/app/src/features/conversations/hooks/useThreadDraft.test.ts b/app/src/features/conversations/hooks/useThreadDraft.test.ts new file mode 100644 index 00000000000..e40e15641b7 --- /dev/null +++ b/app/src/features/conversations/hooks/useThreadDraft.test.ts @@ -0,0 +1,87 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DRAFT_SAVE_DEBOUNCE_MS, draftStorageKey, useThreadDraft } from './useThreadDraft'; + +const store = new Map(); +vi.mock('../../../store/userScopedStorage', () => ({ + userScopedStorage: { + getItem: vi.fn(async (key: string) => store.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + store.set(key, value); + }), + removeItem: vi.fn(async (key: string) => { + store.delete(key); + }), + }, +})); + +async function settle() { + await act(async () => { + await Promise.resolve(); + }); +} + +describe('useThreadDraft', () => { + beforeEach(() => { + store.clear(); + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('restores a stored draft for the thread', async () => { + store.set(draftStorageKey('t1'), 'half typed'); + const { result } = renderHook(() => useThreadDraft('t1')); + await settle(); + expect(result.current[0]).toBe('half typed'); + }); + + it('persists edits after the debounce and clears on empty', async () => { + const { result } = renderHook(() => useThreadDraft('t1')); + await settle(); + act(() => result.current[1]('hello')); + expect(store.has(draftStorageKey('t1'))).toBe(false); + await act(async () => { + vi.advanceTimersByTime(DRAFT_SAVE_DEBOUNCE_MS); + }); + expect(store.get(draftStorageKey('t1'))).toBe('hello'); + + act(() => result.current[1](prev => `${prev}!`)); + expect(result.current[0]).toBe('hello!'); + act(() => result.current[1]('')); + await act(async () => { + vi.advanceTimersByTime(DRAFT_SAVE_DEBOUNCE_MS); + }); + expect(store.has(draftStorageKey('t1'))).toBe(false); + }); + + it('keeps drafts apart per thread and saves a pending edit on switch', async () => { + store.set(draftStorageKey('t2'), 'other thread'); + const { result, rerender } = renderHook(({ id }) => useThreadDraft(id), { + initialProps: { id: 't1' as string | null }, + }); + await settle(); + act(() => result.current[1]('for t1')); + + rerender({ id: 't2' }); + await settle(); + expect(result.current[0]).toBe('other thread'); + // The t1 edit was still inside the debounce window; the switch flushed it. + expect(store.get(draftStorageKey('t1'))).toBe('for t1'); + + rerender({ id: 't1' }); + await settle(); + expect(result.current[0]).toBe('for t1'); + }); + + it('does not delete a stored draft just by mounting', async () => { + store.set(draftStorageKey('t1'), 'keep me'); + renderHook(() => useThreadDraft('t1')); + await act(async () => { + vi.advanceTimersByTime(DRAFT_SAVE_DEBOUNCE_MS * 2); + }); + expect(store.get(draftStorageKey('t1'))).toBe('keep me'); + }); +}); From 34492fbedb204dd6389829bb41a6b99e208fd3f3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:20:47 +0300 Subject: [PATCH 048/126] test(conversations): cover todo list part rendering Add tests for the todo list part in conversations, exercising its rendering paths so regressions in the component are caught. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/aui/TodoListPart.test.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/app/src/features/conversations/aui/TodoListPart.test.tsx b/app/src/features/conversations/aui/TodoListPart.test.tsx index da15f8c6974..e05025c9bb4 100644 --- a/app/src/features/conversations/aui/TodoListPart.test.tsx +++ b/app/src/features/conversations/aui/TodoListPart.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { mapCoreTodoStatus, toAuiTodoItems, TodoListPart } from './TodoListPart'; @@ -52,16 +52,33 @@ const baseProps = { }; describe('TodoListPart', () => { - it('renders the todo list from the tool result', () => { + it('renders a one-line receipt that expands to the snapshot', () => { render( ); - expect(screen.getByText('Write tests')).toBeInTheDocument(); + const toggle = screen.getByRole('button', { expanded: false }); + expect(toggle).toHaveTextContent('1/3'); + expect(toggle).toHaveTextContent('Ship it'); + // Collapsed: the full list is not repeated in the transcript. + expect(screen.queryByText('Celebrate')).toBeNull(); + + fireEvent.click(toggle); + expect(screen.getByRole('button', { expanded: true })).toBeInTheDocument(); + expect(screen.getByText('Celebrate')).toBeInTheDocument(); }); it('renders nothing when there are no items', () => { From 9b55d64187075f00f901b9ef4f09515a4e509243 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:20:52 +0300 Subject: [PATCH 049/126] feat(conversations): add conversation list view Adds a conversations feature component that renders the list of conversations for the current user. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../features/conversations/Conversations.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 2e908bc0b9d..7f8d2a75dd6 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -267,7 +267,9 @@ const Conversations = ({ : false; const firstActiveThreadId = Object.keys(activeThreadIds)[0] ?? null; - const [inputValue, setInputValue] = useState(''); + // Per-thread, reload-surviving draft (`useThreadDraft`): switching threads + // swaps it instead of carrying half-typed text into the next conversation. + const [inputValue, setInputValue] = useThreadDraft(selectedThreadId ?? null); const [attachments, setAttachments] = useState([]); // What ingest counts its budget against. Tracks state on every render (so a // removal or a send's clear is picked up) and is written synchronously as each @@ -589,19 +591,24 @@ const Conversations = ({ // Threads whose turn has gone quiet past the silence window while the core // still reports it running (or cannot say). Drives a warning only — the turn // is never torn down client-side; the Stop button is how the user ends it. - const [stalledThreadIds, setStalledThreadIds] = useState>(() => new Set()); + // Value: epoch ms the thread last showed any inference signal (the stall + // moment minus the silence window), so the warning can say how long it has + // been quiet. + const [stalledThreadIds, setStalledThreadIds] = useState>( + () => new Map() + ); const markThreadStalled = useCallback((threadId: string) => { setStalledThreadIds(prev => { if (prev.has(threadId)) return prev; - const next = new Set(prev); - next.add(threadId); + const next = new Map(prev); + next.set(threadId, Date.now() - SILENCE_WARNING_MS); return next; }); }, []); const clearThreadStalled = useCallback((threadId: string) => { setStalledThreadIds(prev => { if (!prev.has(threadId)) return prev; - const next = new Set(prev); + const next = new Map(prev); next.delete(threadId); return next; }); From a1cfdc824760fd0668a03aa8d0db358b6d5dcf67 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:20:55 +0300 Subject: [PATCH 050/126] feat(conversations): add conversation list view Adds a conversations feature component that renders the list of conversations for the current user. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../features/conversations/Conversations.tsx | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 7f8d2a75dd6..18255829aa2 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -107,6 +107,7 @@ import { } from './aui/ReasoningEffortPicker'; import { ThreadWorkspaceChip } from './aui/ThreadWorkspaceChip'; import { useChatSurfaceRegistration } from './hooks/useChatSurfaceRegistration'; +import { useThreadDraft } from './hooks/useThreadDraft'; import { ThreadList } from './threadList/ThreadList'; import { useThreadPins } from './threadList/useThreadPins'; import { useUnreadThreads } from './threadList/useUnreadThreads'; @@ -1779,20 +1780,14 @@ const Conversations = ({ inferenceTurnLifecycleByThread[selectedThreadId] === 'streaming') ? (inferenceStatusByThread[selectedThreadId]?.phase ?? 'thinking') : null; - const stallWarningBanner = selectedStalledPhase ? ( -
-

- {t( - selectedStalledPhase === 'thinking' - ? 'chat.stallWarning.thinking' - : 'chat.stallWarning.working' - )} -

-
- ) : null; + const stallWarningBanner = + selectedStalledPhase && selectedThreadId ? ( + + ) : null; const sendAdvisoryBanner = sendAdvisory ? (
From c19bed5c2b082650b1b94cac0aa6e9f0e58be68c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:21:02 +0300 Subject: [PATCH 051/126] feat(conversations): add live elapsed time hook Adds a hook that tracks and returns the elapsed time for a live conversation, updating on an interval so callers can render a running timer without managing their own state. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/aui/useLiveElapsed.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 app/src/features/conversations/aui/useLiveElapsed.ts diff --git a/app/src/features/conversations/aui/useLiveElapsed.ts b/app/src/features/conversations/aui/useLiveElapsed.ts new file mode 100644 index 00000000000..3f287843449 --- /dev/null +++ b/app/src/features/conversations/aui/useLiveElapsed.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; + +/** + * Elapsed time for a sub-agent delegation that ticks every second while it + * works, after OpenClaw's live subagent timers. + * + * The core reports a delegation's wall-clock `elapsedMs` only once it settles; + * a running row carries no start time. So the clock is anchored the first time + * this session sees the task running, in a module-scoped map that outlives + * assistant-ui's remounts (virtualised history, thread switches). After a + * reload mid-run the anchor is the reload, so the count restarts — the settled + * duration from the core replaces it as soon as the run finishes. + */ +const MAX_ANCHORS = 500; +const anchors = new Map(); + +function anchorFor(key: string): number { + const existing = anchors.get(key); + if (existing !== undefined) return existing; + const now = Date.now(); + anchors.set(key, now); + if (anchors.size > MAX_ANCHORS) { + const oldest = anchors.keys().next().value; + if (oldest !== undefined) anchors.delete(oldest); + } + return now; +} + +/** Forget every anchor. Exposed for tests. */ +export function resetLiveElapsedAnchors(): void { + anchors.clear(); +} + +/** + * `settledMs` once the run has finished, else live milliseconds since the run + * was first seen; `undefined` when neither is known (no key while running). + */ +export function useLiveElapsed( + key: string | undefined, + running: boolean, + settledMs: number | undefined +): number | undefined { + const ticking = running && key !== undefined; + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!ticking) return undefined; + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [ticking]); + + if (!running) return settledMs; + if (key === undefined) return undefined; + return Math.max(0, now - anchorFor(key)); +} From 136054c585695718532584bfe1595674a978d793 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:21:05 +0300 Subject: [PATCH 052/126] feat(conversations): add subagent task card component Introduce a card that renders a subagent task in the conversation view, showing its status and details so delegated work is visible inline. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/aui/SubagentTaskCard.tsx | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/src/features/conversations/aui/SubagentTaskCard.tsx b/app/src/features/conversations/aui/SubagentTaskCard.tsx index 7d722b336f1..321371e7bca 100644 --- a/app/src/features/conversations/aui/SubagentTaskCard.tsx +++ b/app/src/features/conversations/aui/SubagentTaskCard.tsx @@ -35,6 +35,7 @@ import { subagentApi } from '../../../services/api/subagentApi'; import { type SubagentActivity, subagentCancelResolved } from '../../../store/chatRuntimeSlice'; import { useAppDispatch } from '../../../store/hooks'; import { basename } from '../../../utils/pathUtils'; +import { useLiveElapsed } from './useLiveElapsed'; function asSubagentActivity(value: unknown): SubagentActivity | undefined { if (!value || typeof value !== 'object') return undefined; @@ -233,7 +234,20 @@ export const SubagentTaskCard: ToolCallMessagePartComponent = ({ toolCalls: [], }; const name = resolved.displayName ?? resolved.agentId ?? 'subagent'; - const elapsed = resolved.elapsedMs !== undefined ? formatElapsed(resolved.elapsedMs) : undefined; + const running = state === 'working' || state === 'waiting'; + // Ticks every second while the delegation works; the core's settled + // wall-clock replaces it once the run finishes ("Ran for 1m 4s"). + const elapsedMs = useLiveElapsed( + resolved.taskId !== 'pending-subagent' ? resolved.taskId : undefined, + running, + resolved.elapsedMs + ); + const elapsed = + elapsedMs === undefined + ? undefined + : running + ? formatElapsed(elapsedMs) + : t('chat.subagents.ranFor').replace('{duration}', formatElapsed(elapsedMs)); const awaiting = state === 'waiting' && resolved.status === 'awaiting_user'; // The user's own open/closed choice, remembered by the part's tool-call id // so it survives assistant-ui's remounts (a virtualized history, a thread @@ -245,8 +259,7 @@ export const SubagentTaskCard: ToolCallMessagePartComponent = ({ const [open, setOpen] = useDisclosure(toolCallId ? `subagent:${toolCallId}` : undefined, false); const disclosureOpen = open || awaiting; - const cancellable = - (state === 'working' || state === 'waiting') && resolved.taskId !== 'pending-subagent'; + const cancellable = running && resolved.taskId !== 'pending-subagent'; const actions = awaiting || resolved.worktreePath || cancellable ? ( From bfeeadf167f27a87f8a3f5c37065ef3cfb2a9b22 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:21:14 +0300 Subject: [PATCH 053/126] refactor(conversations): split live elapsed timer into since and running hooks The hook no longer owns a module-scoped anchor map keyed by task id; callers now supply the start timestamp themselves, and a new useRunningSince hook captures it when a row starts running. This removes the cross-session anchor state that leaked between remounts and makes the timer usable by the new stall warning component. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/aui/StallWarning.tsx | 51 +++++++++++++ .../conversations/aui/useLiveElapsed.ts | 73 +++++++------------ 2 files changed, 79 insertions(+), 45 deletions(-) create mode 100644 app/src/features/conversations/aui/StallWarning.tsx diff --git a/app/src/features/conversations/aui/StallWarning.tsx b/app/src/features/conversations/aui/StallWarning.tsx new file mode 100644 index 00000000000..7effcad498c --- /dev/null +++ b/app/src/features/conversations/aui/StallWarning.tsx @@ -0,0 +1,51 @@ +/** + * The "this turn has gone quiet" banner above the composer (after OpenClaw's + * health chips): the phase-specific explanation, how long the turn has been + * silent, and Stop right beside it so the user does not have to hunt for the + * composer's stop button. Informational only — the turn keeps running and the + * banner clears itself on the next inference signal. + */ +import { useT } from '../../../lib/i18n/I18nContext'; +import { formatElapsed } from '../components/AssistantUiToolCall'; +import { useLiveElapsed } from './useLiveElapsed'; + +export interface StallWarningProps { + /** `inferenceStatusByThread.phase` of the quiet turn. */ + phase: string; + /** Epoch ms of the turn's last inference signal. */ + quietSince: number; + onStop: () => void; +} + +/** `75_000` → "1m 15s", rounded to whole seconds for a banner. */ +function formatQuiet(ms: number): string { + return formatElapsed(Math.round(ms / 1000) * 1000); +} + +export function StallWarning({ phase, quietSince, onStop }: StallWarningProps) { + const { t } = useT(); + const quietMs = useLiveElapsed(quietSince, true) ?? 0; + return ( +
+

+ {t(phase === 'thinking' ? 'chat.stallWarning.thinking' : 'chat.stallWarning.working')}{' '} + + {t('chat.status.quietFor').replace('{duration}', formatQuiet(quietMs))} + +

+ +
+ ); +} + +export default StallWarning; diff --git a/app/src/features/conversations/aui/useLiveElapsed.ts b/app/src/features/conversations/aui/useLiveElapsed.ts index 3f287843449..d9ae5440f8c 100644 --- a/app/src/features/conversations/aui/useLiveElapsed.ts +++ b/app/src/features/conversations/aui/useLiveElapsed.ts @@ -1,55 +1,38 @@ import { useEffect, useState } from 'react'; /** - * Elapsed time for a sub-agent delegation that ticks every second while it - * works, after OpenClaw's live subagent timers. - * - * The core reports a delegation's wall-clock `elapsedMs` only once it settles; - * a running row carries no start time. So the clock is anchored the first time - * this session sees the task running, in a module-scoped map that outlives - * assistant-ui's remounts (virtualised history, thread switches). After a - * reload mid-run the anchor is the reload, so the count restarts — the settled - * duration from the core replaces it as soon as the run finishes. + * Milliseconds since `since` (epoch ms), re-rendering once a second while + * `active`. Returns `undefined` when inactive or when `since` is unknown, so a + * caller never shows a frozen clock. */ -const MAX_ANCHORS = 500; -const anchors = new Map(); - -function anchorFor(key: string): number { - const existing = anchors.get(key); - if (existing !== undefined) return existing; - const now = Date.now(); - anchors.set(key, now); - if (anchors.size > MAX_ANCHORS) { - const oldest = anchors.keys().next().value; - if (oldest !== undefined) anchors.delete(oldest); - } - return now; -} - -/** Forget every anchor. Exposed for tests. */ -export function resetLiveElapsedAnchors(): void { - anchors.clear(); -} - -/** - * `settledMs` once the run has finished, else live milliseconds since the run - * was first seen; `undefined` when neither is known (no key while running). - */ -export function useLiveElapsed( - key: string | undefined, - running: boolean, - settledMs: number | undefined -): number | undefined { - const ticking = running && key !== undefined; +export function useLiveElapsed(since: number | undefined, active: boolean): number | undefined { + const ticking = active && since !== undefined; const [now, setNow] = useState(() => Date.now()); - + const [wasTicking, setWasTicking] = useState(ticking); + if (wasTicking !== ticking) { + setWasTicking(ticking); + if (ticking) setNow(Date.now()); + } useEffect(() => { if (!ticking) return undefined; - const id = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(id); + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); }, [ticking]); + if (!ticking || since === undefined) return undefined; + return Math.max(0, now - since); +} - if (!running) return settledMs; - if (key === undefined) return undefined; - return Math.max(0, now - anchorFor(key)); +/** + * Epoch ms at which `running` last became true (or the component mounted + * running), cleared when it stops. For live timers on rows that carry no + * start timestamp of their own. + */ +export function useRunningSince(running: boolean): number | undefined { + const [since, setSince] = useState(() => (running ? Date.now() : undefined)); + const [wasRunning, setWasRunning] = useState(running); + if (wasRunning !== running) { + setWasRunning(running); + setSince(running ? Date.now() : undefined); + } + return since; } From 90ef3cc73a109ac5a4882ef0f14441d14d653dfa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:21:18 +0300 Subject: [PATCH 054/126] feat(conversations): add conversation list view Adds a conversations feature component that renders the list of conversations for the current user. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../features/conversations/Conversations.tsx | 63 +++++++++++++++---- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 18255829aa2..f1d75e9ffaf 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -99,6 +99,7 @@ import { openhumanUpdateRuntimeSettings, } from '../../utils/tauriCommands/config'; import { ApprovalCardAdapter } from './aui/ApprovalCardAdapter'; +import { StallWarning } from './aui/StallWarning'; import { ComposerMessageQueue } from './aui/ComposerMessageQueue'; import { type ReasoningEffortChoice, @@ -1448,8 +1449,19 @@ const Conversations = ({ true ); - const handleComposerEscape = useCallback(() => { - if (!selectedThreadActive) return; + // The last *visible* user prompt (hidden system/injected messages are + // excluded to match how the transcript is rendered), as composer text. + const lastVisibleUserPrompt = useCallback((): string => { + const lastUserMessage = [...messages] + .reverse() + .find(m => m.sender === 'user' && !m.extraMetadata?.hidden); + return lastUserMessage ? parseMessageImages(lastUserMessage.content ?? '').text : ''; + }, [messages]); + + // Returns whether Escape did anything, so the composer only swallows the key + // when it acted and an open popover can still close on it. + const handleComposerEscape = useCallback((): boolean => { + if (!selectedThreadActive) return false; const composerEmpty = inputValue.trim().length === 0; debug( '[chat] esc interrupt: thread=%s composerEmpty=%s', @@ -1458,20 +1470,49 @@ const Conversations = ({ ); handleStopGeneration(); if (composerEmpty) { - // Restore the last *visible* user prompt (hidden system/injected - // messages are excluded here to match how the transcript is rendered). - const lastUserMessage = [...messages] - .reverse() - .find(m => m.sender === 'user' && !m.extraMetadata?.hidden); - const restored = lastUserMessage - ? parseMessageImages(lastUserMessage.content ?? '').text - : ''; + const restored = lastVisibleUserPrompt(); if (restored.length > 0) { debug('[chat] esc interrupt: restored prompt len=%d', restored.length); setInputValue(restored); } } - }, [handleStopGeneration, inputValue, messages, selectedThreadActive, selectedThreadId]); + return true; + }, [ + handleStopGeneration, + inputValue, + lastVisibleUserPrompt, + selectedThreadActive, + selectedThreadId, + setInputValue, + ]); + + // ArrowUp in an empty composer recalls this thread's last prompt for a + // quick resend or tweak. Returns whether it did, so the caret key otherwise + // behaves normally. + const handleRecallLastPrompt = useCallback((): boolean => { + if (inputValue.length > 0) return false; + const restored = lastVisibleUserPrompt(); + if (restored.length === 0) return false; + debug('[chat] arrow-up recall: thread=%s len=%d', selectedThreadId ?? 'none', restored.length); + setInputValue(restored); + return true; + }, [inputValue, lastVisibleUserPrompt, selectedThreadId, setInputValue]); + + // The composer's placeholder follows the thread: a turn parked on the user + // (approval, plan review, drafted workflow) asks for that answer first; a + // turn in flight takes the text as a queued follow-up. + const selectedThreadWaitingOnUser = selectedThreadId + ? Boolean( + pendingApprovalByThread[selectedThreadId] || + pendingPlanReviewByThread[selectedThreadId] || + pendingWorkflowProposalsByThread[selectedThreadId] + ) + : false; + const composerPlaceholder = selectedThreadWaitingOnUser + ? t('composer.placeholder.waiting') + : selectedThreadActive + ? t('composer.placeholder.running') + : undefined; // The transcript itself renders from the assistant-ui runtime // (`AssistantUiChat`). What remains here is what the composer footer and the From 06977df2a3595d40945caca7b1c26e42d4b27a0d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:21:24 +0300 Subject: [PATCH 055/126] feat(conversations): show subagent and todo progress in parallel agent cards Parallel agent cards now render the subagent list and todo list parts so in-flight work is visible while agents run. The subagent list element was added to support this display. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../assistant-ui/elements/subagent-list.tsx | 7 ++- .../conversations/aui/ParallelAgentsCard.tsx | 49 ++++++++++++++++--- .../conversations/aui/TodoListPart.tsx | 2 +- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/app/src/components/assistant-ui/elements/subagent-list.tsx b/app/src/components/assistant-ui/elements/subagent-list.tsx index 8acd5d15813..d77d96932b3 100644 --- a/app/src/components/assistant-ui/elements/subagent-list.tsx +++ b/app/src/components/assistant-ui/elements/subagent-list.tsx @@ -6,6 +6,9 @@ * Changes from upstream: * - `cn` import path (`@/components/assistant-ui/lib/utils`). * - `../utils/range` -> `@/components/assistant-ui/utils/range`. + * - `SubagentItem.done`: an agent's own finished flag. Upstream marks the + * first `completedCount` rows done, which is only right when workers finish + * in list order; parallel workers do not. * No hard-coded user-facing copy — `agent.name`/`agent.model` are * caller-supplied props, so there is nothing to route through `useT()` here. */ @@ -19,6 +22,8 @@ import { mono, paper } from './surfaces'; export interface SubagentItem { name: string; model: string; + /** This agent has finished; falls back to `index < completedCount` when absent. */ + done?: boolean; } export function SubagentList({ @@ -45,7 +50,7 @@ export function SubagentList({ className={cn('flex min-h-[14.5rem] w-full max-w-xs flex-col gap-2', className)} {...props}> {agents.map((agent, index) => { - const done = index < completedCount; + const done = agent.done ?? index < completedCount; const width = progress[index] ?? 0; const percentage = pct(width, 100); diff --git a/app/src/features/conversations/aui/ParallelAgentsCard.tsx b/app/src/features/conversations/aui/ParallelAgentsCard.tsx index 38f26c08990..74c3ae563a2 100644 --- a/app/src/features/conversations/aui/ParallelAgentsCard.tsx +++ b/app/src/features/conversations/aui/ParallelAgentsCard.tsx @@ -58,8 +58,12 @@ function childProgressPct( return 50; } +function isFailedChild(status: string | undefined): boolean { + return status === 'error' || status === 'failed' || status === 'cancelled'; +} + /** Adapt a `spawn_parallel_agents` tool-call part onto `SubagentList` + per-child `TaskCard` rows. */ -export const ParallelAgentsCard: ToolCallMessagePartComponent = ({ toolCallId }) => { +export const ParallelAgentsCard: ToolCallMessagePartComponent = ({ toolCallId, status }) => { const { t } = useT(); const threadId = useAuiThreadId(); const timeline = useAppSelector(state => @@ -69,15 +73,48 @@ export const ParallelAgentsCard: ToolCallMessagePartComponent = ({ toolCallId }) if (children.length === 0) return null; - const agents: SubagentItem[] = children.map(entry => ({ name: childName(entry), model: '' })); - const completedCount = children.filter( - entry => !isActiveTimelineStatus(entry.subagent?.status ?? entry.status) - ).length; + const childStatus = (entry: (typeof children)[number]) => entry.subagent?.status ?? entry.status; + const agents: SubagentItem[] = children.map(entry => ({ + name: childName(entry), + model: '', + done: !isActiveTimelineStatus(childStatus(entry)), + })); + const completedCount = agents.filter(agent => agent.done).length; + const runningCount = children.length - completedCount; + const failedCount = children.filter(entry => isFailedChild(childStatus(entry))).length; const progress = children.map(childProgressPct); - const anyRunning = completedCount < children.length; + const anyRunning = runningCount > 0; + // Every worker is back but the parent call has not settled: it is reading + // their results, which used to look like the card had simply stalled. + const aggregating = !anyRunning && status?.type === 'running'; + const counts = [ + runningCount > 0 + ? t('chat.subagents.runningCount').replace('{count}', String(runningCount)) + : null, + failedCount > 0 + ? t('chat.subagents.failedCount').replace('{count}', String(failedCount)) + : null, + ].filter(Boolean); return (
+
+ + {t('chat.subagents.ofTotal') + .replace('{complete}', String(completedCount)) + .replace('{total}', String(children.length))} + + {counts.length > 0 && ( + {counts.join(' · ')} + )} +
+ {aggregating && ( +

+ {t('chat.subagents.settled')} +

+ )} Date: Wed, 7 Oct 2026 01:21:28 +0300 Subject: [PATCH 056/126] feat(conversations): render assistant ui tool calls Add a component that renders tool call parts from assistant-ui messages, showing the tool name and its arguments so users can see what the assistant invoked during a conversation. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/components/AssistantUiToolCall.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/features/conversations/components/AssistantUiToolCall.tsx b/app/src/features/conversations/components/AssistantUiToolCall.tsx index 8332d362e3d..3ea79ce4777 100644 --- a/app/src/features/conversations/components/AssistantUiToolCall.tsx +++ b/app/src/features/conversations/components/AssistantUiToolCall.tsx @@ -14,6 +14,7 @@ import type { } from '../../../store/chatRuntimeSlice'; import { openUrl } from '../../../utils/openUrl'; import { ToolFailureCard } from '../aui/ToolFailureCard'; +import { useLiveElapsed, useRunningSince } from '../aui/useLiveElapsed'; import { isSearchBalanceError } from '../tools/parseWebSearchResult'; import { FetchBody, @@ -127,6 +128,11 @@ export function AssistantUiToolCallCard({ const doneLabel = toolLabel({ ...presentation, tense: 'done' }, t); const failed = status === 'error'; const awaiting = awaitingUser || status === 'awaiting_user'; + // Live clock while the tool runs (not while it waits on the user, whose + // time is not the tool's). Tool parts carry no start timestamp, so the clock + // starts when the row first renders running — after a remount it restarts. + const liveRunning = running && !awaiting; + const liveMs = useLiveElapsed(useRunningSince(liveRunning), liveRunning); const outcome: ToolCallOutcome = failed ? 'error' : status === 'cancelled' @@ -216,6 +222,10 @@ export function AssistantUiToolCallCard({ {formatElapsed(elapsedMs)} + ) : liveMs !== undefined && liveMs >= 1000 ? ( + + {formatElapsed(Math.floor(liveMs / 1000) * 1000)} + ) : null} } From e621fad4e6fa6c0cb9664fa3cfe4490a148078a7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:21:31 +0300 Subject: [PATCH 057/126] feat(conversations): add conversation list view Adds a conversations feature component that renders the list of conversations for the current user. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/features/conversations/Conversations.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index f1d75e9ffaf..ba3b3f5f298 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -406,6 +406,10 @@ const Conversations = ({ const pendingPlanReviewByThread = useAppSelector( state => state.chatRuntime.pendingPlanReviewByThread ); + // Optional-chain + default: narrow test stores may omit the field. + const pendingApprovalByThread = useAppSelector( + state => state.chatRuntime.pendingApprovalByThread ?? EMPTY_PENDING_APPROVALS + ); const pendingWorkflowProposalsByThread = useAppSelector( state => state.chatRuntime.pendingWorkflowProposalsByThread ); From 5cc78eee1b5b7757c48d52bcc2e251369a7a4669 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:21:33 +0300 Subject: [PATCH 058/126] feat(conversations): add conversation list view Add a Conversations feature component that renders the list of conversations for the current user. This gives the app a dedicated place to browse and select conversations. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/features/conversations/Conversations.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index ba3b3f5f298..7f0d9b11841 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -164,6 +164,7 @@ interface ConversationsProps { // object identity when the slice field is absent (narrow test stores), // avoiding spurious re-renders. const EMPTY_ACTIVE_THREADS: Record = {}; +const EMPTY_PENDING_APPROVALS: Record = {}; // Stable empty live tool-timeline / processing-transcript for the selected // thread. A fresh `[]` here took a new identity every render, invalidating the From d4283a512c11a83b7de6aab134ae8667e337f703 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:21:40 +0300 Subject: [PATCH 059/126] feat(conversations): add conversation list view Add a Conversations feature component that renders the list of conversations for the current user. This gives the app a dedicated place to browse and select conversations. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/features/conversations/Conversations.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 7f0d9b11841..03188eace25 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -2129,6 +2129,8 @@ const Conversations = ({ inputValue={inputValue} onInputValueChange={setInputValue} onEscape={handleComposerEscape} + onRecallLastPrompt={handleRecallLastPrompt} + composerPlaceholder={composerPlaceholder} attachments={attachments} onAttachFiles={handleAttachFiles} onRemoveAttachment={id => setAttachments(previous => previous.filter(a => a.id !== id))} From 48b958b11b49e2effdd9a72c06e7ec13d775a414 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:21:45 +0300 Subject: [PATCH 060/126] feat(conversations): show live elapsed time on subagent task cards Subagent task cards now display a running elapsed timer while a task is in progress, driven by a new useSubagentElapsed hook. This gives users a sense of how long a subagent has been working without needing to inspect logs. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/aui/SubagentTaskCard.tsx | 4 +- .../conversations/aui/useSubagentElapsed.ts | 56 +++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 app/src/features/conversations/aui/useSubagentElapsed.ts diff --git a/app/src/features/conversations/aui/SubagentTaskCard.tsx b/app/src/features/conversations/aui/SubagentTaskCard.tsx index 321371e7bca..31e752c98cb 100644 --- a/app/src/features/conversations/aui/SubagentTaskCard.tsx +++ b/app/src/features/conversations/aui/SubagentTaskCard.tsx @@ -35,7 +35,7 @@ import { subagentApi } from '../../../services/api/subagentApi'; import { type SubagentActivity, subagentCancelResolved } from '../../../store/chatRuntimeSlice'; import { useAppDispatch } from '../../../store/hooks'; import { basename } from '../../../utils/pathUtils'; -import { useLiveElapsed } from './useLiveElapsed'; +import { useSubagentElapsed } from './useSubagentElapsed'; function asSubagentActivity(value: unknown): SubagentActivity | undefined { if (!value || typeof value !== 'object') return undefined; @@ -237,7 +237,7 @@ export const SubagentTaskCard: ToolCallMessagePartComponent = ({ const running = state === 'working' || state === 'waiting'; // Ticks every second while the delegation works; the core's settled // wall-clock replaces it once the run finishes ("Ran for 1m 4s"). - const elapsedMs = useLiveElapsed( + const elapsedMs = useSubagentElapsed( resolved.taskId !== 'pending-subagent' ? resolved.taskId : undefined, running, resolved.elapsedMs diff --git a/app/src/features/conversations/aui/useSubagentElapsed.ts b/app/src/features/conversations/aui/useSubagentElapsed.ts new file mode 100644 index 00000000000..9eab1b17ddf --- /dev/null +++ b/app/src/features/conversations/aui/useSubagentElapsed.ts @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react'; + +/** + * Elapsed time for a sub-agent delegation that ticks every second while it + * works, after OpenClaw's live subagent timers. + * + * The core reports a delegation's wall-clock `elapsedMs` only once it settles; + * a running row carries no start time. So the clock is anchored the first time + * this session sees the task running, in a module-scoped map keyed by task id + * that outlives assistant-ui's remounts (virtualised history, thread + * switches). After a reload mid-run the anchor is the reload, so the count + * restarts — the settled duration from the core replaces it as soon as the + * run finishes. + */ +const MAX_ANCHORS = 500; +const anchors = new Map(); + +function anchorFor(key: string): number { + const existing = anchors.get(key); + if (existing !== undefined) return existing; + const now = Date.now(); + anchors.set(key, now); + if (anchors.size > MAX_ANCHORS) { + const oldest = anchors.keys().next().value; + if (oldest !== undefined) anchors.delete(oldest); + } + return now; +} + +/** Forget every anchor. Exposed for tests. */ +export function resetSubagentElapsedAnchors(): void { + anchors.clear(); +} + +/** + * `settledMs` once the run has finished, else live milliseconds since the task + * was first seen running; `undefined` when neither is known. + */ +export function useSubagentElapsed( + taskId: string | undefined, + running: boolean, + settledMs: number | undefined +): number | undefined { + const ticking = running && taskId !== undefined; + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!ticking) return undefined; + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, [ticking]); + + if (!running) return settledMs; + if (taskId === undefined) return undefined; + return Math.max(0, now - anchorFor(taskId)); +} From 58bad39acb225714a477271c3fb969e5d9e8c087 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:21:50 +0300 Subject: [PATCH 061/126] =?UTF-8?q?chore:=20I=20don't=20see=20a=20diff=20i?= =?UTF-8?q?n=20your=20message=20=E2=80=94=20the=20Files,=20Stat,=20and=20D?= =?UTF-8?q?iff=20sections=20are=20all=20empty.=20Coul?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/components/AssistantUiChat.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/features/conversations/components/AssistantUiChat.tsx b/app/src/features/conversations/components/AssistantUiChat.tsx index 2e1f9ce52f0..76718a0b599 100644 --- a/app/src/features/conversations/components/AssistantUiChat.tsx +++ b/app/src/features/conversations/components/AssistantUiChat.tsx @@ -11,6 +11,7 @@ import type { Attachment } from '../../../lib/attachments'; import { useT } from '../../../lib/i18n/I18nContext'; import { AssistantUiRuntimeProvider } from '../../../providers/AssistantUiRuntimeProvider'; import { useAppSelector } from '../../../store/hooks'; +import { InterruptedTurnNotice } from '../aui/InterruptedTurnNotice'; import { AgentRunningStatus } from '../aui/AgentRunningStatus'; import { ChatConversationMap } from '../aui/ChatConversationMap'; import { ComposerTriggers } from '../aui/ComposerTriggers'; @@ -113,6 +114,8 @@ export function AssistantUiChat({ inputValue, onInputValueChange, onEscape, + onRecallLastPrompt, + composerPlaceholder, attachments, onAttachFiles, onRemoveAttachment, @@ -142,7 +145,12 @@ export function AssistantUiChat({ composerReplacement?: ReactNode; inputValue: string; onInputValueChange: (value: string) => void; - onEscape?: () => void; + /** Returns whether Escape acted (stopped a run); `false` lets the key through. */ + onEscape?: () => boolean | void; + /** ArrowUp in an empty composer: recall the last prompt; returns whether it did. */ + onRecallLastPrompt?: () => boolean; + /** State-aware placeholder (running / waiting on the user); default otherwise. */ + composerPlaceholder?: string; attachments: Attachment[]; onAttachFiles: (files: FileList | File[] | null) => Promise; onRemoveAttachment: (id: string) => void; @@ -314,6 +322,8 @@ export function AssistantUiChat({ // Phase / reasoning round / active tool for the turn in flight. Reads the // runtime's `extras`, so it needs no props and no dependency here. RunningStatus: AgentRunningStatus, + // A crashed turn's partial reply and its "Interrupted" marker. + TranscriptFooter: InterruptedTurnNotice, // The web pages the turn fetched, grouped from its `source` parts into // one collapsed disclosure under the answer. SourceGroup: ChatSources, @@ -367,6 +377,8 @@ export function AssistantUiChat({ onModelChange={onModelChange} loadError={loadError} onEscape={onEscape} + onRecallLastPrompt={onRecallLastPrompt} + composerPlaceholder={composerPlaceholder} /> From e3499cfe64b701b9437c83eb6fc654ada27133f1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:21:57 +0300 Subject: [PATCH 062/126] feat(conversations): add assistant ui chat component Introduce a chat component for the conversations feature that renders assistant messages using the assistant-ui library. This provides the foundation for the conversational interface. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/features/conversations/components/AssistantUiChat.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/features/conversations/components/AssistantUiChat.tsx b/app/src/features/conversations/components/AssistantUiChat.tsx index 76718a0b599..4d25609dd76 100644 --- a/app/src/features/conversations/components/AssistantUiChat.tsx +++ b/app/src/features/conversations/components/AssistantUiChat.tsx @@ -12,6 +12,7 @@ import { useT } from '../../../lib/i18n/I18nContext'; import { AssistantUiRuntimeProvider } from '../../../providers/AssistantUiRuntimeProvider'; import { useAppSelector } from '../../../store/hooks'; import { InterruptedTurnNotice } from '../aui/InterruptedTurnNotice'; +import { InterruptedTurnNotice } from '../aui/InterruptedTurnNotice'; import { AgentRunningStatus } from '../aui/AgentRunningStatus'; import { ChatConversationMap } from '../aui/ChatConversationMap'; import { ComposerTriggers } from '../aui/ComposerTriggers'; From 5b947f569bc3774eacf312fc8509f1086c6163e0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:22:02 +0300 Subject: [PATCH 063/126] =?UTF-8?q?chore:=20I=20don't=20see=20a=20diff=20i?= =?UTF-8?q?n=20your=20message=20=E2=80=94=20the=20Files,=20Stat,=20and=20D?= =?UTF-8?q?iff=20sections=20are=20all=20empty.=20Coul?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/features/conversations/components/AssistantUiChat.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/features/conversations/components/AssistantUiChat.tsx b/app/src/features/conversations/components/AssistantUiChat.tsx index 4d25609dd76..76718a0b599 100644 --- a/app/src/features/conversations/components/AssistantUiChat.tsx +++ b/app/src/features/conversations/components/AssistantUiChat.tsx @@ -12,7 +12,6 @@ import { useT } from '../../../lib/i18n/I18nContext'; import { AssistantUiRuntimeProvider } from '../../../providers/AssistantUiRuntimeProvider'; import { useAppSelector } from '../../../store/hooks'; import { InterruptedTurnNotice } from '../aui/InterruptedTurnNotice'; -import { InterruptedTurnNotice } from '../aui/InterruptedTurnNotice'; import { AgentRunningStatus } from '../aui/AgentRunningStatus'; import { ChatConversationMap } from '../aui/ChatConversationMap'; import { ComposerTriggers } from '../aui/ComposerTriggers'; From c6fd629338b09b2747c7276f7b52d6dc885c7cbc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:22:11 +0300 Subject: [PATCH 064/126] feat(assistant-ui): group consecutive tool calls into activity groups Consecutive tool calls in the assistant thread are now collapsed into a single activity group so long runs of tool use read as one unit instead of a wall of individual entries. Added tests covering the grouping behaviour. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../assistant-ui/activity-group.tsx | 51 ++++++++++++++++--- .../thread.activityGroup.test.tsx | 24 +++++++-- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/app/src/components/assistant-ui/activity-group.tsx b/app/src/components/assistant-ui/activity-group.tsx index a30eb4e0abe..367dc5aa979 100644 --- a/app/src/components/assistant-ui/activity-group.tsx +++ b/app/src/components/assistant-ui/activity-group.tsx @@ -16,23 +16,46 @@ import { ToolGroupTrigger, } from '@/components/assistant-ui/elements/tool-group'; import { OpenHumanReasoningGroup } from '@/components/assistant-ui/reasoning-group'; +import { formatElapsed } from '@/components/assistant-ui/utils/task'; +import { useT } from '@/lib/i18n/I18nContext'; import { type MessagePrimitive, useAuiState } from '@assistant-ui/react'; import { type FC, type PropsWithChildren, useState } from 'react'; export type ActivityGroupPart = MessagePrimitive.GroupedParts.GroupPart; +type Translate = (key: string) => string; + /** * Trigger text for a run of reasoning and tool calls. * * Tool calls are what the reader counts; reasoning is either there or not, so * it is named rather than counted. The group only exists when it holds at * least one of the two, so the empty fallback is never shown in practice. + * + * `workedMs`, when known, turns a settled group into OpenClaw's turn receipt: + * "Worked for 42s · 3 tool calls". */ -export function activityGroupLabel(reasoningCount: number, toolCount: number): string { - const tools = toolCount > 0 ? `${toolCount} tool ${toolCount === 1 ? 'call' : 'calls'}` : null; - if (reasoningCount > 0 && tools) return `Reasoning · ${tools}`; - if (reasoningCount > 0) return 'Reasoning'; - return tools ?? 'Activity'; +export function activityGroupLabel( + reasoningCount: number, + toolCount: number, + t: Translate, + workedMs?: number +): string { + const tools = + toolCount > 0 + ? t(toolCount === 1 ? 'chat.tools.callOne' : 'chat.tools.callOther').replace( + '{count}', + String(toolCount) + ) + : null; + if (workedMs !== undefined && tools) { + return t('chat.tools.workedFor') + .replace('{duration}', formatElapsed(workedMs)) + .replace('{calls}', tools); + } + if (reasoningCount > 0 && tools) return t('chat.tools.reasoningWithCalls').replace('{calls}', tools); + if (reasoningCount > 0) return t('chat.tools.reasoning'); + return tools ?? t('chat.tools.activity'); } /** @@ -73,6 +96,17 @@ export const ActivityGroup: FC> const requiresAction = useAuiState(s => indices.some(i => s.message.parts[i]?.status?.type === 'requires-action') ); + // The turn's wall time (`chat_done.timing.total_ms`), credited to this group + // only when it holds every tool call in the message: a message with several + // activity groups cannot say which share of the time each one took. + const messageToolCount = useAuiState( + s => s.message.parts.filter(part => part?.type === 'tool-call').length + ); + const totalStreamTime = useAuiState( + s => (s.message.metadata as { timing?: { totalStreamTime?: number } } | undefined)?.timing + ?.totalStreamTime + ); + const { t } = useT(); const [userOpen, setUserOpen] = useState(null); const running = group.status.type === 'running' || isTail; @@ -88,7 +122,12 @@ export const ActivityGroup: FC> {children} diff --git a/app/src/components/assistant-ui/thread.activityGroup.test.tsx b/app/src/components/assistant-ui/thread.activityGroup.test.tsx index 9e2c0ead5b2..650ca00963e 100644 --- a/app/src/components/assistant-ui/thread.activityGroup.test.tsx +++ b/app/src/components/assistant-ui/thread.activityGroup.test.tsx @@ -174,10 +174,26 @@ describe('activity group', () => { }); describe('activityGroupLabel', () => { + const EN: Record = { + 'chat.tools.callOne': '{count} tool call', + 'chat.tools.callOther': '{count} tool calls', + 'chat.tools.reasoningWithCalls': 'Reasoning · {calls}', + 'chat.tools.reasoning': 'Reasoning', + 'chat.tools.activity': 'Activity', + 'chat.tools.workedFor': 'Worked for {duration} · {calls}', + }; + const t = (key: string) => EN[key] ?? key; + it('names reasoning and counts tool calls', () => { - expect(activityGroupLabel(2, 1)).toBe('Reasoning · 1 tool call'); - expect(activityGroupLabel(1, 0)).toBe('Reasoning'); - expect(activityGroupLabel(0, 3)).toBe('3 tool calls'); - expect(activityGroupLabel(0, 0)).toBe('Activity'); + expect(activityGroupLabel(2, 1, t)).toBe('Reasoning · 1 tool call'); + expect(activityGroupLabel(1, 0, t)).toBe('Reasoning'); + expect(activityGroupLabel(0, 3, t)).toBe('3 tool calls'); + expect(activityGroupLabel(0, 0, t)).toBe('Activity'); + }); + + it('reads as a receipt once the turn time is known', () => { + expect(activityGroupLabel(1, 3, t, 42_000)).toBe('Worked for 42s · 3 tool calls'); + // Without a tool call there is nothing to credit the time to. + expect(activityGroupLabel(1, 0, t, 42_000)).toBe('Reasoning'); }); }); From 2459a408b783c4850b4821a94798cb6c77cf1822 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:22:16 +0300 Subject: [PATCH 065/126] feat(i18n): add interrupted turn notice strings Added Arabic and English translations for the interrupted turn notice shown in conversations. The notice component now pulls its copy from the i18n layer so both locales render the same message. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../aui/InterruptedTurnNotice.tsx | 46 +++++++++++++++++++ app/src/lib/i18n/ar.ts | 8 ++++ app/src/lib/i18n/en.ts | 8 ++++ 3 files changed, 62 insertions(+) create mode 100644 app/src/features/conversations/aui/InterruptedTurnNotice.tsx diff --git a/app/src/features/conversations/aui/InterruptedTurnNotice.tsx b/app/src/features/conversations/aui/InterruptedTurnNotice.tsx new file mode 100644 index 00000000000..783c4b47a2f --- /dev/null +++ b/app/src/features/conversations/aui/InterruptedTurnNotice.tsx @@ -0,0 +1,46 @@ +/** + * End-of-transcript marker for a turn that was cut off — the core crashed or + * restarted mid-reply — rather than stopped by the user (that is + * `StoppedRunSlot`). `chatRuntime.interruptedAssistantByThread` is hydrated + * from the turn-state snapshot with whatever partial text the turn had + * streamed; without this the partial was recorded and never shown, so a + * crashed turn looked like it had simply never answered. + * + * Styled like the stopped-run element so the two read as siblings. It + * disappears on its own when a new turn starts (the slice clears the entry). + */ +import { useT } from '../../../lib/i18n/I18nContext'; +import { useAppSelector } from '../../../store/hooks'; + +export function InterruptedTurnNotice() { + const { t } = useT(); + const interrupted = useAppSelector(state => { + const threadId = state.thread.selectedThreadId; + if (!threadId) return null; + return state.chatRuntime?.interruptedAssistantByThread?.[threadId] ?? null; + }); + if (!interrupted) return null; + const partial = interrupted.content.trim(); + return ( +
+ {partial.length > 0 && ( +

+ {partial} +

+ )} +
+ + + {t('chat.message.interrupted')} + + {t('chat.message.interruptedDetail')} +
+
+ ); +} + +export default InterruptedTurnNotice; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index b40e8aa46da..4f05c32b6b1 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -2100,6 +2100,14 @@ const messages: TranslationMap = { 'conversations.composer.mention.memory': 'الذاكرة', 'conversations.composer.mention.files': 'الملفات', 'conversations.todos.title': 'المهام', + 'chat.todos.completed': 'مكتمل', + 'chat.todos.ofTotal': '{done} من {total}', + 'chat.todos.receipt': 'تم تحديث التقدم — {done}/{total}', + 'chat.subagents.ranFor': 'استغرق {duration}', + 'chat.subagents.ofTotal': '{complete} من {total}', + 'chat.subagents.runningCount': '{count} قيد التشغيل', + 'chat.subagents.failedCount': '{count} فشل', + 'chat.subagents.settled': 'انتهت المهام الفرعية. تجري معالجة نتائجها.', 'conversations.planReview.title': 'مراجعة الخطة', 'conversations.planReview.subtitle': 'وافق لتشغيلها، أو ارفضها لتجاهلها، أو أرسل ملاحظات لتعديلها.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index d6ef60e2931..9de82972bc1 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -2450,6 +2450,14 @@ const en: TranslationMap = { 'conversations.composer.mention.memory': 'Memory', 'conversations.composer.mention.files': 'Files', 'conversations.todos.title': 'Todos', + 'chat.todos.completed': 'Completed', + 'chat.todos.ofTotal': '{done} of {total}', + 'chat.todos.receipt': 'Progress updated — {done}/{total}', + 'chat.subagents.ranFor': 'Ran for {duration}', + 'chat.subagents.ofTotal': '{complete} of {total}', + 'chat.subagents.runningCount': '{count} running', + 'chat.subagents.failedCount': '{count} failed', + 'chat.subagents.settled': 'Child runs finished. Processing their results.', 'conversations.planReview.title': 'Review plan', 'conversations.planReview.subtitle': 'Approve to run it, reject to discard, or send feedback to revise.', From 81a71892526b6755154729742f42dd36524afd15 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:22:18 +0300 Subject: [PATCH 066/126] feat(i18n): add bengali translations Added Bengali locale strings so the interface can be displayed in Bengali. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/bn.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index cdd0eca6b73..eb739e4faee 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -2142,6 +2142,14 @@ const messages: TranslationMap = { 'conversations.composer.mention.memory': 'মেমরি', 'conversations.composer.mention.files': 'ফাইল', 'conversations.todos.title': 'কাজের তালিকা', + 'chat.todos.completed': 'সম্পন্ন', + 'chat.todos.ofTotal': '{total}-এর মধ্যে {done}', + 'chat.todos.receipt': 'অগ্রগতি হালনাগাদ — {done}/{total}', + 'chat.subagents.ranFor': '{duration} ধরে চলেছে', + 'chat.subagents.ofTotal': '{total}-এর মধ্যে {complete}', + 'chat.subagents.runningCount': '{count}টি চলছে', + 'chat.subagents.failedCount': '{count}টি ব্যর্থ', + 'chat.subagents.settled': 'সহকারী কাজগুলো শেষ হয়েছে। তাদের ফলাফল প্রক্রিয়া করা হচ্ছে।', 'conversations.planReview.title': 'পরিকল্পনা পর্যালোচনা করুন', 'conversations.planReview.subtitle': 'চালানোর জন্য অনুমোদন করুন, বাতিল করতে প্রত্যাখ্যান করুন, অথবা সংশোধনের জন্য মতামত পাঠান।', From 49c765443f6e93013958ccb3c0a1e1d47da047de Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:22:21 +0300 Subject: [PATCH 067/126] docs(i18n): add German translations Added German locale strings to the i18n catalog so the app can be displayed in German. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/de.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 76c2f22c084..4b08c9f1b42 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -2207,6 +2207,14 @@ const messages: TranslationMap = { 'conversations.composer.mention.memory': 'Gedächtnis', 'conversations.composer.mention.files': 'Dateien', 'conversations.todos.title': 'Aufgaben', + 'chat.todos.completed': 'Abgeschlossen', + 'chat.todos.ofTotal': '{done} von {total}', + 'chat.todos.receipt': 'Fortschritt aktualisiert — {done}/{total}', + 'chat.subagents.ranFor': 'Lief {duration}', + 'chat.subagents.ofTotal': '{complete} von {total}', + 'chat.subagents.runningCount': '{count} laufen', + 'chat.subagents.failedCount': '{count} fehlgeschlagen', + 'chat.subagents.settled': 'Unteraufgaben beendet. Ergebnisse werden verarbeitet.', 'conversations.planReview.title': 'Plan prüfen', 'conversations.planReview.subtitle': 'Genehmigen zum Ausführen, ablehnen zum Verwerfen oder Feedback zum Überarbeiten senden.', From 6af0085c079f45d0cbde6e0b7e8777408f2c3cc3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:22:24 +0300 Subject: [PATCH 068/126] docs(i18n): add Spanish translations Add Spanish locale strings covering the app's user-facing text so the interface can be displayed in Spanish. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/es.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 91e4e115972..f2340732aee 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -2181,6 +2181,14 @@ const messages: TranslationMap = { 'conversations.composer.mention.memory': 'Memoria', 'conversations.composer.mention.files': 'Archivos', 'conversations.todos.title': 'Tareas', + 'chat.todos.completed': 'Completado', + 'chat.todos.ofTotal': '{done} de {total}', + 'chat.todos.receipt': 'Progreso actualizado — {done}/{total}', + 'chat.subagents.ranFor': 'Duró {duration}', + 'chat.subagents.ofTotal': '{complete} de {total}', + 'chat.subagents.runningCount': '{count} en curso', + 'chat.subagents.failedCount': '{count} con error', + 'chat.subagents.settled': 'Las subtareas terminaron. Procesando sus resultados.', 'conversations.planReview.title': 'Revisar plan', 'conversations.planReview.subtitle': 'Aprueba para ejecutarlo, recházalo para descartarlo o envía comentarios para revisarlo.', From 19b9025f488907b4f28a20b781352d594d83dccb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:22:26 +0300 Subject: [PATCH 069/126] feat(assistant-ui): add tool group element Add a ToolGroup component that renders a collapsible group of tool calls with per-tool status and expandable details. French translations were extended to cover the new labels. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/components/assistant-ui/elements/tool-group.tsx | 8 +++++++- app/src/lib/i18n/fr.ts | 8 ++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/app/src/components/assistant-ui/elements/tool-group.tsx b/app/src/components/assistant-ui/elements/tool-group.tsx index eb523b67500..793222b4b2a 100644 --- a/app/src/components/assistant-ui/elements/tool-group.tsx +++ b/app/src/components/assistant-ui/elements/tool-group.tsx @@ -16,8 +16,11 @@ * default "N tool calls" text (used by `ActivityGroup` for a mixed * reasoning + tool run) and renders a shimmer duplicate of the label while * `active`, matching `tool-fallback`'s running-label treatment. + * - The default "N tool calls" text is translated through `useT()` + * (`chat.tools.callOne` / `chat.tools.callOther`). */ import { cn } from '@/components/assistant-ui/lib/utils'; +import { useT } from '@/lib/i18n/I18nContext'; import { Collapsible, CollapsibleContent, @@ -105,7 +108,10 @@ function ToolGroupTrigger({ label?: string; active?: boolean; }) { - const label = labelOverride ?? `${count} tool ${count === 1 ? 'call' : 'calls'}`; + const { t } = useT(); + const label = + labelOverride ?? + t(count === 1 ? 'chat.tools.callOne' : 'chat.tools.callOther').replace('{count}', String(count)); return ( Date: Wed, 7 Oct 2026 01:22:30 +0300 Subject: [PATCH 070/126] feat(i18n): add Hindi translations Added a Hindi locale file with translations for the app's user-facing strings so the interface can be displayed in Hindi. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/hi.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index f2bf036f753..0a2bb5e389d 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -2144,6 +2144,14 @@ const messages: TranslationMap = { 'conversations.composer.mention.memory': 'मेमोरी', 'conversations.composer.mention.files': 'फ़ाइलें', 'conversations.todos.title': 'कार्य सूची', + 'chat.todos.completed': 'पूरा हुआ', + 'chat.todos.ofTotal': '{total} में से {done}', + 'chat.todos.receipt': 'प्रगति अपडेट हुई — {done}/{total}', + 'chat.subagents.ranFor': '{duration} तक चला', + 'chat.subagents.ofTotal': '{total} में से {complete}', + 'chat.subagents.runningCount': '{count} चल रहे हैं', + 'chat.subagents.failedCount': '{count} विफल', + 'chat.subagents.settled': 'उप-कार्य पूरे हुए। उनके परिणाम संसाधित हो रहे हैं।', 'conversations.planReview.title': 'योजना की समीक्षा करें', 'conversations.planReview.subtitle': 'इसे चलाने के लिए स्वीकृत करें, हटाने के लिए अस्वीकार करें, या संशोधित करने के लिए प्रतिक्रिया भेजें।', From 7c241641a8c8d7e2ac589fecb4a1f662d85154fa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:22:33 +0300 Subject: [PATCH 071/126] feat(assistant-ui): add Indonesian translations for thread component Added Indonesian locale strings for the assistant thread UI and wired the thread component to use them, so the chat interface displays properly for Indonesian-speaking users. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/components/assistant-ui/thread.tsx | 78 +++++++++++++++++++--- app/src/lib/i18n/id.ts | 8 +++ 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/app/src/components/assistant-ui/thread.tsx b/app/src/components/assistant-ui/thread.tsx index 9559e695ffd..4f56334a16c 100644 --- a/app/src/components/assistant-ui/thread.tsx +++ b/app/src/components/assistant-ui/thread.tsx @@ -138,6 +138,12 @@ export type ThreadComponents = { * component returns `null` when it has nothing to say. */ RunningStatus?: ComponentType | undefined; + /** + * Host-owned content under the last message while no turn is running — e.g. + * an "Interrupted" marker for a turn the host knows was cut off. Returns + * `null` when there is nothing to show. + */ + TranscriptFooter?: ComponentType | undefined; /** Host-owned attachment previews rendered above the editor. */ ComposerAttachments?: ComponentType | undefined; /** Host-owned attachment picker rendered in the action row. */ @@ -199,8 +205,19 @@ export type ThreadProps = { onModelChange?: ((value: string | null, contextWindow?: number | null) => void) | undefined; /** Host transport error shown in place of an empty welcome state. */ loadError?: string | null | undefined; - /** Host-specific Escape behavior (for example cancel + restore prompt). */ - onEscape?: (() => void) | undefined; + /** + * Host-specific Escape behavior (for example cancel + restore prompt). + * Returning `false` means it did nothing, and the key is left to anything + * else listening (an open popover); any other return swallows it. + */ + onEscape?: (() => boolean | void) | undefined; + /** + * ArrowUp in an EMPTY composer (no modifiers, no IME): recall the last + * prompt. Returns whether it did; only then is the key consumed. + */ + onRecallLastPrompt?: (() => boolean) | undefined; + /** Placeholder override for the composer input (run / waiting states). */ + composerPlaceholder?: string | undefined; /** * Commands offered when the composer input starts with `/`. Supplied by the * host because a command's `execute` is host behaviour (`/clear` has to @@ -367,6 +384,8 @@ export const Thread: FC = ({ onModelChange, loadError = null, onEscape, + onRecallLastPrompt, + composerPlaceholder, slashCommands = NO_SLASH_COMMANDS, }) => { const isEmpty = useAuiState(isNewChatView); @@ -380,6 +399,8 @@ export const Thread: FC = ({ onModelChange={onModelChange} loadError={loadError} onEscape={onEscape} + onRecallLastPrompt={onRecallLastPrompt} + composerPlaceholder={composerPlaceholder} /> @@ -391,8 +412,18 @@ const ThreadRoot: FC<{ model: string | null; onModelChange?: (value: string | null, contextWindow?: number | null) => void; loadError: string | null; - onEscape?: () => void; -}> = ({ isEmpty, model, onModelChange, loadError, onEscape }) => { + onEscape?: () => boolean | void; + onRecallLastPrompt?: () => boolean; + composerPlaceholder?: string; +}> = ({ + isEmpty, + model, + onModelChange, + loadError, + onEscape, + onRecallLastPrompt, + composerPlaceholder, +}) => { const { t } = useT(); const { Welcome = ThreadWelcome, @@ -461,6 +492,7 @@ const ThreadRoot: FC<{ className="mb-14 flex flex-col gap-y-6 empty:hidden"> {() => } +
isNewChatView(s) && s.composer.isEmpty}> @@ -931,10 +965,12 @@ export function extractComposerPasteFiles( const Composer: FC<{ model: string | null; onModelChange?: (value: string | null, contextWindow?: number | null) => void; - onEscape?: () => void; + onEscape?: () => boolean | void; + onRecallLastPrompt?: () => boolean; + placeholder?: string; /** A file drag is over the thread and will land here; see `useThreadFileDrop`. */ isDraggingFiles: boolean; -}> = ({ model, onModelChange, onEscape, isDraggingFiles }) => { +}> = ({ model, onModelChange, onEscape, onRecallLastPrompt, placeholder, isDraggingFiles }) => { const { t } = useT(); const messageInputLabel = t('assistantUi.thread.messageInputLabel', 'Message input'); const aui = useAui(); @@ -1101,7 +1137,7 @@ const Composer: FC<{ */} { isComposingTextRef.current = true; }} @@ -1135,13 +1171,33 @@ const Composer: FC<{ syncComposerFromDom(event.target); }} onKeyDownCapture={event => { + const native = event.nativeEvent; if (event.key === 'Escape' && onEscape) { - event.preventDefault(); - event.stopPropagation(); - onEscape(); + // Only swallow the key when the host acted; otherwise an open + // `/` or `@` popover still gets to close on it. + if (onEscape() !== false) { + event.preventDefault(); + event.stopPropagation(); + } + return; + } + if ( + event.key === 'ArrowUp' && + onRecallLastPrompt && + !event.shiftKey && + !event.altKey && + !event.metaKey && + !event.ctrlKey && + !isComposingTextRef.current && + !native.isComposing && + aui.composer().getState().text.length === 0 + ) { + if (onRecallLastPrompt()) { + event.preventDefault(); + event.stopPropagation(); + } return; } - const native = event.nativeEvent; if ( isComposingTextRef.current || native.isComposing || diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 31c35c737a4..ffaf1ccd076 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -2161,6 +2161,14 @@ const messages: TranslationMap = { 'conversations.composer.mention.memory': 'Memori', 'conversations.composer.mention.files': 'Berkas', 'conversations.todos.title': 'Tugas', + 'chat.todos.completed': 'Selesai', + 'chat.todos.ofTotal': '{done} dari {total}', + 'chat.todos.receipt': 'Progres diperbarui — {done}/{total}', + 'chat.subagents.ranFor': 'Berjalan selama {duration}', + 'chat.subagents.ofTotal': '{complete} dari {total}', + 'chat.subagents.runningCount': '{count} berjalan', + 'chat.subagents.failedCount': '{count} gagal', + 'chat.subagents.settled': 'Subtugas selesai. Memproses hasilnya.', 'conversations.planReview.title': 'Tinjau rencana', 'conversations.planReview.subtitle': 'Setujui untuk menjalankannya, tolak untuk membuangnya, atau kirim masukan untuk merevisinya.', From ab718b6f999cfdb080f9175e5d5677f32a3ad3c6 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:22:35 +0300 Subject: [PATCH 072/126] feat(i18n): add italian translations for chat todos and subagents Added Italian strings for the chat todo progress and subagent status messages so these new UI elements display translated text instead of falling back to English. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/it.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 0a97dfd7906..0dcf95dfd5c 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -2181,6 +2181,14 @@ const messages: TranslationMap = { 'conversations.composer.mention.memory': 'Memoria', 'conversations.composer.mention.files': 'File', 'conversations.todos.title': 'Attività', + 'chat.todos.completed': 'Completato', + 'chat.todos.ofTotal': '{done} di {total}', + 'chat.todos.receipt': 'Avanzamento aggiornato — {done}/{total}', + 'chat.subagents.ranFor': 'Durata {duration}', + 'chat.subagents.ofTotal': '{complete} di {total}', + 'chat.subagents.runningCount': '{count} in corso', + 'chat.subagents.failedCount': '{count} non riusciti', + 'chat.subagents.settled': 'Sottoattività concluse. Elaborazione dei risultati.', 'conversations.planReview.title': 'Rivedi il piano', 'conversations.planReview.subtitle': 'Approva per eseguirlo, rifiuta per scartarlo o invia un feedback per rivederlo.', From 56264b3f17ed55ce34e8e818cb59380704fb346f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:22:37 +0300 Subject: [PATCH 073/126] feat(conversations): add Korean translations for chat tool parts Localized the chat tool part labels and status strings so Korean users see translated text instead of English fallbacks. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../features/conversations/components/ChatToolParts.tsx | 8 ++++++++ app/src/lib/i18n/ko.ts | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/app/src/features/conversations/components/ChatToolParts.tsx b/app/src/features/conversations/components/ChatToolParts.tsx index 2f36aa214c8..4f1d6ac54fd 100644 --- a/app/src/features/conversations/components/ChatToolParts.tsx +++ b/app/src/features/conversations/components/ChatToolParts.tsx @@ -144,6 +144,7 @@ const ASK_USER_CLARIFICATION_TOOL = 'ask_user_clarification'; */ const ElicitationCall: ToolCallMessagePartComponent = ({ args, result }) => { const aui = useAui(); + const { t } = useT(); const question = (args as { question?: string } | undefined)?.question ?? ''; const answer = useCallback( (text: string) => { @@ -151,12 +152,19 @@ const ElicitationCall: ToolCallMessagePartComponent = ({ args, result }) => { }, [aui] ); + // Declining is an answer too: the run is parked until a user turn arrives, + // so a Decline that sent nothing left the turn waiting forever. It sends a + // plain "carry on without it" reply the orchestrator reads like any other. + const decline = useCallback(() => { + answer(t('chat.elicitation.declineReply')); + }, [answer, t]); return ( ); diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 2ef154e1e80..c8bfb9c0a62 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -2131,6 +2131,14 @@ const messages: TranslationMap = { 'conversations.composer.mention.memory': '메모리', 'conversations.composer.mention.files': '파일', 'conversations.todos.title': '할 일', + 'chat.todos.completed': '완료됨', + 'chat.todos.ofTotal': '{total}개 중 {done}개', + 'chat.todos.receipt': '진행 상황 업데이트 — {done}/{total}', + 'chat.subagents.ranFor': '{duration} 동안 실행됨', + 'chat.subagents.ofTotal': '{total}개 중 {complete}개', + 'chat.subagents.runningCount': '{count}개 실행 중', + 'chat.subagents.failedCount': '{count}개 실패', + 'chat.subagents.settled': '하위 작업이 끝났습니다. 결과를 처리하는 중입니다.', 'conversations.planReview.title': '계획 검토', 'conversations.planReview.subtitle': '실행하려면 승인하고, 버리려면 거부하거나, 수정하려면 의견을 보내세요.', From ee3e99bbb1f49041cc2a4b3edd2c2244cdfec0a4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:22:39 +0300 Subject: [PATCH 074/126] =?UTF-8?q?chore:=20I=20don't=20see=20a=20diff=20i?= =?UTF-8?q?n=20your=20message=20=E2=80=94=20the=20Files,=20Stat,=20and=20D?= =?UTF-8?q?iff=20sections=20are=20all=20empty.=20Coul?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/components/assistant-ui/thread.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/app/src/components/assistant-ui/thread.tsx b/app/src/components/assistant-ui/thread.tsx index 4f56334a16c..7b59352a1f7 100644 --- a/app/src/components/assistant-ui/thread.tsx +++ b/app/src/components/assistant-ui/thread.tsx @@ -894,6 +894,17 @@ const RunningStatusSlot: FC = () => { ); }; +/** Host footer under the last message, shown only while nothing is running. */ +const TranscriptFooterSlot: FC = () => { + const { TranscriptFooter } = useContext(ThreadComponentsContext); + if (!TranscriptFooter) return null; + return ( + !s.thread.isRunning}> + + + ); +}; + const ThreadMessage: FC = () => { const { AssistantMessage: AssistantMessageComponent = AssistantMessage } = useContext(ThreadComponentsContext); @@ -1007,6 +1018,9 @@ const Composer: FC<{ // composition before it runs. That stale write would rebuild the editor // mid-composition and cancel it -- #5763 again, one composition later. const isComposingTextRef = useRef(false); + // ArrowUp recall only fires on an empty composer, so a caret move inside a + // multi-line draft is never hijacked. + const composerIsEmpty = useAuiState(state => state.composer.text.length === 0); // DOM text -> composer store. The text is read at event time; only the write // is deferred by a microtask, so the editor has finished applying the event @@ -1190,7 +1204,7 @@ const Composer: FC<{ !event.ctrlKey && !isComposingTextRef.current && !native.isComposing && - aui.composer().getState().text.length === 0 + composerIsEmpty ) { if (onRecallLastPrompt()) { event.preventDefault(); From f7d8185ec1a9b47765310067b1985e175df3eea3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:22:45 +0300 Subject: [PATCH 075/126] feat(i18n): add Polish translations Added Polish locale strings to the i18n module so the app can display Polish text. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/pl.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 3e6ba0f71a2..86d9aa8782a 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -2171,6 +2171,14 @@ const messages: TranslationMap = { 'conversations.composer.mention.memory': 'Pamięć', 'conversations.composer.mention.files': 'Pliki', 'conversations.todos.title': 'Zadania', + 'chat.todos.completed': 'Ukończono', + 'chat.todos.ofTotal': '{done} z {total}', + 'chat.todos.receipt': 'Zaktualizowano postęp — {done}/{total}', + 'chat.subagents.ranFor': 'Trwało {duration}', + 'chat.subagents.ofTotal': '{complete} z {total}', + 'chat.subagents.runningCount': 'W toku: {count}', + 'chat.subagents.failedCount': 'Nieudane: {count}', + 'chat.subagents.settled': 'Zadania podrzędne zakończone. Przetwarzanie ich wyników.', 'conversations.planReview.title': 'Przejrzyj plan', 'conversations.planReview.subtitle': 'Zatwierdź, aby go uruchomić, odrzuć, aby go odrzucić, lub wyślij opinię, aby go poprawić.', From 282df99011cd94a417a6cb3d649abc35c14dd4fc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:22:48 +0300 Subject: [PATCH 076/126] feat(i18n): add Portuguese, Russian, and Simplified Chinese locales Added translation files for pt, ru, and zh-CN so the app can be used in those languages. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/pt.ts | 8 ++++++++ app/src/lib/i18n/ru.ts | 8 ++++++++ app/src/lib/i18n/zh-CN.ts | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 5a856be58cc..ae52deb36e9 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -2179,6 +2179,14 @@ const messages: TranslationMap = { 'conversations.composer.mention.memory': 'Memória', 'conversations.composer.mention.files': 'Arquivos', 'conversations.todos.title': 'Tarefas', + 'chat.todos.completed': 'Concluído', + 'chat.todos.ofTotal': '{done} de {total}', + 'chat.todos.receipt': 'Progresso atualizado — {done}/{total}', + 'chat.subagents.ranFor': 'Durou {duration}', + 'chat.subagents.ofTotal': '{complete} de {total}', + 'chat.subagents.runningCount': '{count} em execução', + 'chat.subagents.failedCount': '{count} com falha', + 'chat.subagents.settled': 'As subtarefas terminaram. Processando os resultados.', 'conversations.planReview.title': 'Revisar plano', 'conversations.planReview.subtitle': 'Aprove para executá-lo, rejeite para descartá-lo ou envie comentários para revisá-lo.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index d477a26650c..3d6b7bcab54 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -2159,6 +2159,14 @@ const messages: TranslationMap = { 'conversations.composer.mention.memory': 'Память', 'conversations.composer.mention.files': 'Файлы', 'conversations.todos.title': 'Задачи', + 'chat.todos.completed': 'Завершено', + 'chat.todos.ofTotal': '{done} из {total}', + 'chat.todos.receipt': 'Прогресс обновлён — {done}/{total}', + 'chat.subagents.ranFor': 'Длилось {duration}', + 'chat.subagents.ofTotal': '{complete} из {total}', + 'chat.subagents.runningCount': 'Выполняется: {count}', + 'chat.subagents.failedCount': 'С ошибкой: {count}', + 'chat.subagents.settled': 'Подзадачи завершены. Обрабатываем их результаты.', 'conversations.planReview.title': 'Проверить план', 'conversations.planReview.subtitle': 'Одобрите для запуска, отклоните для отмены или отправьте отзыв для доработки.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index e0752e9fa7e..8f76a11c63e 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2000,6 +2000,14 @@ const messages: TranslationMap = { 'conversations.composer.mention.memory': '记忆', 'conversations.composer.mention.files': '文件', 'conversations.todos.title': '待办', + 'chat.todos.completed': '已完成', + 'chat.todos.ofTotal': '{done} / {total}', + 'chat.todos.receipt': '进度已更新 — {done}/{total}', + 'chat.subagents.ranFor': '运行了 {duration}', + 'chat.subagents.ofTotal': '{complete} / {total}', + 'chat.subagents.runningCount': '{count} 个运行中', + 'chat.subagents.failedCount': '{count} 个失败', + 'chat.subagents.settled': '子任务已完成,正在处理结果。', 'conversations.planReview.title': '审阅计划', 'conversations.planReview.subtitle': '批准以执行,拒绝以放弃,或发送反馈以修改。', 'conversations.planReview.approve': '批准并执行', From b61392f06fd792debd0ab92cf185c99f3d376cb5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:22:53 +0300 Subject: [PATCH 077/126] =?UTF-8?q?chore:=20I=20don't=20see=20a=20diff=20i?= =?UTF-8?q?n=20your=20message=20=E2=80=94=20the=20"Diff:"=20section=20is?= =?UTF-8?q?=20empty,=20and=20the=20stat=20is=20blank=20to?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Could you paste the actual diff? Without it I can't tell what changed (added strings, removed strings, reworded copy, etc.), and guessing would risk describing the change in the wrong direction. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/en.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 9de82972bc1..1ccf9159e73 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1755,6 +1755,21 @@ const en: TranslationMap = { 'chat.failedToLoadMessages': 'Failed to load messages', 'chat.thinkingIteration': 'Thinking... ({n})', 'chat.thinkingDots': 'Thinking...', + 'chat.status.thinkingElapsed': 'Thinking · {elapsed}', + 'chat.status.waitingApproval': 'Waiting for your approval', + 'chat.status.waitingReview': 'Waiting for your review', + 'chat.status.waitingAnswer': 'Waiting for your answer', + 'chat.status.waitingOnSubagent': 'Waiting on {name}', + 'chat.status.waitingOnSubagents': 'Waiting on {count} subagents', + 'chat.status.quietFor': 'Quiet for {duration}.', + 'chat.status.stop': 'Stop', + 'chat.tools.callOne': '{count} tool call', + 'chat.tools.callOther': '{count} tool calls', + 'chat.tools.reasoningWithCalls': 'Reasoning · {calls}', + 'chat.tools.reasoning': 'Reasoning', + 'chat.tools.activity': 'Activity', + 'chat.tools.workedFor': 'Worked for {duration} · {calls}', + 'chat.elicitation.declineReply': "I'd rather not answer that. Please continue without it.", 'chat.upgrade': 'Upgrade', 'chat.weeklyLimitHit': "You've used your included cycle budget.", 'chat.resets': 'Resets', From aa510a6d5ecc1da6b9f139662da9047711aa8e70 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:22:56 +0300 Subject: [PATCH 078/126] feat(assistant-ui): add thread component Introduce a thread component for the assistant UI to render conversation messages. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/components/assistant-ui/thread.tsx | 54 ++++++++++++++++------ 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/app/src/components/assistant-ui/thread.tsx b/app/src/components/assistant-ui/thread.tsx index 7b59352a1f7..305c638e72b 100644 --- a/app/src/components/assistant-ui/thread.tsx +++ b/app/src/components/assistant-ui/thread.tsx @@ -1380,17 +1380,43 @@ const ComposerAction: FC<{ )} - s.thread.isRunning}> + {/* + While a turn runs, typed text is a queued follow-up (the runtime's + `queue` capability keeps Send enabled), so the primary slot offers to + queue it; Stop takes the slot only when there is nothing to send — + OpenClaw's rule, and the one that keeps a half-typed follow-up one + Enter away instead of behind a Stop button. + */} + s.thread.isRunning && s.composer.text.trim().length > 0}> + + + + + + + s.thread.isRunning && s.composer.text.trim().length === 0}> - +
@@ -1663,7 +1689,7 @@ const AssistantActionBar: FC = () => { const reloadAction = canReload && !isFailedTurn ? ( - + @@ -1675,7 +1701,7 @@ const AssistantActionBar: FC = () => { autohide="not-last" className="aui-assistant-action-bar-root text-muted-foreground animate-in fade-in col-start-3 row-start-2 -ms-1 flex gap-1 duration-200"> - + s.message.isCopied}> @@ -1701,7 +1727,7 @@ const AssistantActionBar: FC = () => { unconditionally, so they are always live here. */} @@ -1709,7 +1735,7 @@ const AssistantActionBar: FC = () => { @@ -1727,14 +1753,14 @@ const AssistantActionBar: FC = () => { s.thread.capabilities.speech}> s.message.speech == null}> - + s.message.speech != null}> - + @@ -1742,7 +1768,9 @@ const AssistantActionBar: FC = () => { - + @@ -1837,7 +1865,7 @@ const UserActionBar: FC = () => { // is an ordinary statement, instrumented like any other. const editAction = canEdit ? ( - + @@ -1850,8 +1878,8 @@ const UserActionBar: FC = () => { className="aui-user-action-bar-root flex flex-col items-end"> + tooltip={t('chat.message.copyMessage')} + title={t('chat.message.copyMessage')}> From c670216757a0bfc73eb2107a68a5af7ff2e29ac2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:23:01 +0300 Subject: [PATCH 079/126] feat(i18n): add Arabic and German translations Added Arabic and German locale files to extend the app's internationalization coverage to two more languages. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/ar.ts | 15 +++++++++++++++ app/src/lib/i18n/de.ts | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 4f05c32b6b1..b614612a1f3 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -1495,6 +1495,21 @@ const messages: TranslationMap = { 'chat.failedToLoadMessages': 'فشل تحميل الرسائل', 'chat.thinkingIteration': 'جارٍ التفكير... ({n})', 'chat.thinkingDots': 'جارٍ التفكير...', + 'chat.status.thinkingElapsed': 'جارٍ التفكير · {elapsed}', + 'chat.status.waitingApproval': 'في انتظار موافقتك', + 'chat.status.waitingReview': 'في انتظار مراجعتك', + 'chat.status.waitingAnswer': 'في انتظار إجابتك', + 'chat.status.waitingOnSubagent': 'في انتظار {name}', + 'chat.status.waitingOnSubagents': 'في انتظار {count} من الوكلاء الفرعيين', + 'chat.status.quietFor': 'بلا نشاط منذ {duration}.', + 'chat.status.stop': 'إيقاف', + 'chat.tools.callOne': '{count} استدعاء أداة', + 'chat.tools.callOther': '{count} استدعاءات أدوات', + 'chat.tools.reasoningWithCalls': 'استدلال · {calls}', + 'chat.tools.reasoning': 'استدلال', + 'chat.tools.activity': 'نشاط', + 'chat.tools.workedFor': 'عمل لمدة {duration} · {calls}', + 'chat.elicitation.declineReply': 'أفضّل عدم الإجابة عن ذلك. يرجى المتابعة من دونه.', 'chat.upgrade': 'الترقية', 'chat.weeklyLimitHit': 'لقد وصلت إلى حدك الأسبوعي.', 'chat.resets': 'إعادة ضبط', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 4b08c9f1b42..96a36f26965 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -1572,6 +1572,21 @@ const messages: TranslationMap = { 'chat.failedToLoadMessages': 'Nachrichten konnten nicht geladen werden', 'chat.thinkingIteration': 'Denken... ({n})', 'chat.thinkingDots': 'Denken...', + 'chat.status.thinkingElapsed': 'Denkt nach · {elapsed}', + 'chat.status.waitingApproval': 'Wartet auf deine Freigabe', + 'chat.status.waitingReview': 'Wartet auf deine Prüfung', + 'chat.status.waitingAnswer': 'Wartet auf deine Antwort', + 'chat.status.waitingOnSubagent': 'Wartet auf {name}', + 'chat.status.waitingOnSubagents': 'Wartet auf {count} Subagenten', + 'chat.status.quietFor': 'Seit {duration} still.', + 'chat.status.stop': 'Stoppen', + 'chat.tools.callOne': '{count} Tool-Aufruf', + 'chat.tools.callOther': '{count} Tool-Aufrufe', + 'chat.tools.reasoningWithCalls': 'Überlegung · {calls}', + 'chat.tools.reasoning': 'Überlegung', + 'chat.tools.activity': 'Aktivität', + 'chat.tools.workedFor': 'Gearbeitet für {duration} · {calls}', + 'chat.elicitation.declineReply': 'Das möchte ich lieber nicht beantworten. Bitte mach ohne weiter.', 'chat.upgrade': 'Upgrade', 'chat.weeklyLimitHit': 'Du hast dein enthaltenes Zyklusbudget aufgebraucht.', 'chat.resets': 'Zurücksetzen', From 372a83e4f9a0a8909188899e7cc39f610423369e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:23:05 +0300 Subject: [PATCH 080/126] docs(i18n): add French translations for new UI strings Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/fr.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 659373602df..06c9225b263 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -1569,6 +1569,21 @@ const messages: TranslationMap = { 'chat.failedToLoadMessages': 'Échec du chargement des messages', 'chat.thinkingIteration': 'Réflexion… ({n})', 'chat.thinkingDots': 'Réflexion…', + 'chat.status.thinkingElapsed': 'Réflexion · {elapsed}', + 'chat.status.waitingApproval': 'En attente de votre approbation', + 'chat.status.waitingReview': 'En attente de votre relecture', + 'chat.status.waitingAnswer': 'En attente de votre réponse', + 'chat.status.waitingOnSubagent': 'En attente de {name}', + 'chat.status.waitingOnSubagents': 'En attente de {count} sous-agents', + 'chat.status.quietFor': 'Silencieux depuis {duration}.', + 'chat.status.stop': 'Arrêter', + 'chat.tools.callOne': '{count} appel d’outil', + 'chat.tools.callOther': '{count} appels d’outils', + 'chat.tools.reasoningWithCalls': 'Raisonnement · {calls}', + 'chat.tools.reasoning': 'Raisonnement', + 'chat.tools.activity': 'Activité', + 'chat.tools.workedFor': 'A travaillé {duration} · {calls}', + 'chat.elicitation.declineReply': 'Je préfère ne pas répondre. Continuez sans cette information.', 'chat.upgrade': 'Passer à la version supérieure', 'chat.weeklyLimitHit': 'Tu as atteint ta limite hebdomadaire.', 'chat.resets': 'Réinitialisation', From b9b4b362363daa9076c8129305abb6dd5ec5bb63 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:23:09 +0300 Subject: [PATCH 081/126] feat(assistant-ui): add thread component Introduce a thread component for the assistant UI to render conversation messages. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/components/assistant-ui/thread.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/components/assistant-ui/thread.tsx b/app/src/components/assistant-ui/thread.tsx index 305c638e72b..e3eb0bb3c8a 100644 --- a/app/src/components/assistant-ui/thread.tsx +++ b/app/src/components/assistant-ui/thread.tsx @@ -917,10 +917,11 @@ const ThreadMessage: FC = () => { }; const ThreadScrollToBottom: FC = () => { + const { t } = useT(); return ( From 497e2f050171c97ec68199b721e2ebe8cdba73f9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:23:13 +0300 Subject: [PATCH 082/126] feat(i18n): add spanish and italian translations Added Spanish and Italian locale files so the app can be used in those languages. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/es.ts | 15 +++++++++++++++ app/src/lib/i18n/it.ts | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index f2340732aee..06591fd0296 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -1559,6 +1559,21 @@ const messages: TranslationMap = { 'chat.failedToLoadMessages': 'No se pudieron cargar los mensajes', 'chat.thinkingIteration': 'Pensando... ({n})', 'chat.thinkingDots': 'Pensando...', + 'chat.status.thinkingElapsed': 'Pensando · {elapsed}', + 'chat.status.waitingApproval': 'Esperando tu aprobación', + 'chat.status.waitingReview': 'Esperando tu revisión', + 'chat.status.waitingAnswer': 'Esperando tu respuesta', + 'chat.status.waitingOnSubagent': 'Esperando a {name}', + 'chat.status.waitingOnSubagents': 'Esperando a {count} subagentes', + 'chat.status.quietFor': 'Sin actividad desde hace {duration}.', + 'chat.status.stop': 'Detener', + 'chat.tools.callOne': '{count} llamada a herramienta', + 'chat.tools.callOther': '{count} llamadas a herramientas', + 'chat.tools.reasoningWithCalls': 'Razonamiento · {calls}', + 'chat.tools.reasoning': 'Razonamiento', + 'chat.tools.activity': 'Actividad', + 'chat.tools.workedFor': 'Trabajó durante {duration} · {calls}', + 'chat.elicitation.declineReply': 'Prefiero no responder a eso. Continúa sin ello, por favor.', 'chat.upgrade': 'Mejorar plan', 'chat.weeklyLimitHit': 'Alcanzaste tu límite semanal.', 'chat.resets': 'Se restablece', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 0dcf95dfd5c..625ed73845e 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -1557,6 +1557,21 @@ const messages: TranslationMap = { 'chat.failedToLoadMessages': 'Impossibile caricare i messaggi', 'chat.thinkingIteration': 'Sto pensando... ({n})', 'chat.thinkingDots': 'Sto pensando...', + 'chat.status.thinkingElapsed': 'Sto pensando · {elapsed}', + 'chat.status.waitingApproval': 'In attesa della tua approvazione', + 'chat.status.waitingReview': 'In attesa della tua revisione', + 'chat.status.waitingAnswer': 'In attesa della tua risposta', + 'chat.status.waitingOnSubagent': 'In attesa di {name}', + 'chat.status.waitingOnSubagents': 'In attesa di {count} subagenti', + 'chat.status.quietFor': 'Inattivo da {duration}.', + 'chat.status.stop': 'Interrompi', + 'chat.tools.callOne': '{count} chiamata a strumento', + 'chat.tools.callOther': '{count} chiamate a strumenti', + 'chat.tools.reasoningWithCalls': 'Ragionamento · {calls}', + 'chat.tools.reasoning': 'Ragionamento', + 'chat.tools.activity': 'Attività', + 'chat.tools.workedFor': 'Ha lavorato per {duration} · {calls}', + 'chat.elicitation.declineReply': 'Preferisco non rispondere. Continua pure senza.', 'chat.upgrade': 'Aggiorna', 'chat.weeklyLimitHit': 'Hai raggiunto il limite settimanale.', 'chat.resets': 'Reset', From 1435e701fbba10054cf0be8ada8e0671a950a986 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:23:17 +0300 Subject: [PATCH 083/126] feat(i18n): add korean translations Added Korean locale strings so the interface can be displayed in Korean. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/ko.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index c8bfb9c0a62..af2876a760f 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -1514,6 +1514,21 @@ const messages: TranslationMap = { 'chat.failedToLoadMessages': '메시지를 불러오지 못했습니다', 'chat.thinkingIteration': '생각 중... ({n})', 'chat.thinkingDots': '생각 중...', + 'chat.status.thinkingElapsed': '생각 중 · {elapsed}', + 'chat.status.waitingApproval': '승인을 기다리는 중', + 'chat.status.waitingReview': '검토를 기다리는 중', + 'chat.status.waitingAnswer': '답변을 기다리는 중', + 'chat.status.waitingOnSubagent': '{name} 기다리는 중', + 'chat.status.waitingOnSubagents': '하위 에이전트 {count}개 기다리는 중', + 'chat.status.quietFor': '{duration} 동안 응답 없음.', + 'chat.status.stop': '중지', + 'chat.tools.callOne': '도구 호출 {count}회', + 'chat.tools.callOther': '도구 호출 {count}회', + 'chat.tools.reasoningWithCalls': '추론 · {calls}', + 'chat.tools.reasoning': '추론', + 'chat.tools.activity': '활동', + 'chat.tools.workedFor': '{duration} 동안 작업 · {calls}', + 'chat.elicitation.declineReply': '그 질문에는 답하지 않을게요. 없이 계속 진행해 주세요.', 'chat.upgrade': '업그레이드', 'chat.weeklyLimitHit': '포함된 주기 예산을 모두 사용했습니다.', 'chat.resets': '초기화', From 1545afd5232a2b669e5a04e96ff1164492aa4afa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:23:21 +0300 Subject: [PATCH 084/126] feat(i18n): add Indonesian translations Added Indonesian locale strings and test coverage for the todo list, pinned todo card, and subagent elapsed time components. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../assistant-ui/elements/todo-list.test.tsx | 85 +++++++++++++++++++ .../conversations/aui/PinnedTodoCard.test.tsx | 63 ++++++++++++++ .../aui/useSubagentElapsed.test.ts | 39 +++++++++ app/src/lib/i18n/id.ts | 15 ++++ 4 files changed, 202 insertions(+) create mode 100644 app/src/components/assistant-ui/elements/todo-list.test.tsx create mode 100644 app/src/features/conversations/aui/PinnedTodoCard.test.tsx create mode 100644 app/src/features/conversations/aui/useSubagentElapsed.test.ts diff --git a/app/src/components/assistant-ui/elements/todo-list.test.tsx b/app/src/components/assistant-ui/elements/todo-list.test.tsx new file mode 100644 index 00000000000..9d23fc20465 --- /dev/null +++ b/app/src/components/assistant-ui/elements/todo-list.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { type TodoItem, todoProgress, TodoProgressCard, TodoReceipt } from './todo-list'; + +const items: TodoItem[] = [ + { id: '1', text: 'Read the spec', status: 'done' }, + { id: '2', text: 'Write the code', status: 'active' }, + { id: '3', text: 'Ship it', status: 'pending' }, +]; + +describe('todoProgress', () => { + it('names the first active step and its position', () => { + const progress = todoProgress(items); + expect(progress).toMatchObject({ done: 1, total: 3, position: 2, anyActive: true }); + expect(progress.current?.text).toBe('Write the code'); + }); + + it('falls back to the first pending step, then the last done one', () => { + const pending = todoProgress([ + { id: '1', text: 'A', status: 'done' }, + { id: '2', text: 'B', status: 'pending' }, + ]); + expect(pending.current?.text).toBe('B'); + const finished = todoProgress([ + { id: '1', text: 'A', status: 'done' }, + { id: '2', text: 'B', status: 'done' }, + ]); + expect(finished).toMatchObject({ allDone: true, position: 2 }); + expect(finished.current?.text).toBe('B'); + }); + + it('handles an empty list', () => { + expect(todoProgress([])).toMatchObject({ total: 0, position: 0, allDone: false }); + }); +}); + +describe('TodoProgressCard', () => { + const labels = { title: 'Todos', completedLabel: 'Completed', countLabel: '1 of 3' }; + + it('collapsed shows the current step and pos/total, not the list', () => { + render(); + const toggle = screen.getByRole('button', { name: 'Todos' }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(toggle).toHaveTextContent('Write the code'); + expect(screen.getByTestId('todo-progress-count')).toHaveTextContent('2/3'); + expect(screen.queryByText('Ship it')).toBeNull(); + }); + + it('expanded shows the count label and every step', () => { + const onOpenChange = vi.fn(); + render(); + expect(screen.getByTestId('todo-progress-count')).toHaveTextContent('1 of 3'); + expect(screen.getByText('Ship it')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Todos' })); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it('says completed once every step is done', () => { + render( + ({ ...item, status: 'done' as const }))} + open={false} + onOpenChange={vi.fn()} + {...labels} + /> + ); + expect(screen.getByTestId('todo-progress-count')).toHaveTextContent('Completed'); + }); +}); + +describe('TodoReceipt', () => { + it('is one line that expands to the snapshot', () => { + const onOpenChange = vi.fn(); + const { rerender } = render( + + ); + expect(screen.getByRole('button')).toHaveTextContent('Progress 1/3 · Write the code'); + expect(screen.queryByText('Ship it')).toBeNull(); + fireEvent.click(screen.getByRole('button')); + expect(onOpenChange).toHaveBeenCalledWith(true); + rerender(); + expect(screen.getByText('Ship it')).toBeInTheDocument(); + }); +}); diff --git a/app/src/features/conversations/aui/PinnedTodoCard.test.tsx b/app/src/features/conversations/aui/PinnedTodoCard.test.tsx new file mode 100644 index 00000000000..7a6afb69a04 --- /dev/null +++ b/app/src/features/conversations/aui/PinnedTodoCard.test.tsx @@ -0,0 +1,63 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { TodoItem } from '../../../components/assistant-ui/elements/todo-list'; +import { PinnedTodoCard, TODO_CARD_OPEN_KEY } from './PinnedTodoCard'; + +vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) })); + +const store = vi.hoisted(() => new Map()); +vi.mock('../../../store/userScopedStorage', () => ({ + userScopedStorage: { + getItem: async (key: string) => store.get(key) ?? null, + setItem: async (key: string, value: string) => { + store.set(key, value); + }, + }, +})); + +const active: TodoItem[] = [ + { id: '1', text: 'Step one', status: 'done' }, + { id: '2', text: 'Step two', status: 'active' }, +]; +const idle: TodoItem[] = [ + { id: '1', text: 'Step one', status: 'done' }, + { id: '2', text: 'Step two', status: 'pending' }, +]; + +function toggle() { + return screen.getByRole('button', { name: 'conversations.todos.title' }); +} + +describe('PinnedTodoCard', () => { + beforeEach(() => store.clear()); + + it('opens while a step is active and stays collapsed otherwise', async () => { + const { rerender } = render(); + await waitFor(() => expect(toggle()).toHaveAttribute('aria-expanded', 'true')); + rerender(); + expect(toggle()).toHaveAttribute('aria-expanded', 'false'); + }); + + it('remembers a manual choice per thread', async () => { + const { unmount } = render(); + await act(async () => {}); + fireEvent.click(toggle()); + expect(toggle()).toHaveAttribute('aria-expanded', 'false'); + await waitFor(() => + expect(JSON.parse(store.get(TODO_CARD_OPEN_KEY) ?? '{}')).toEqual({ t1: false }) + ); + unmount(); + + // The remembered choice wins over the active-step default on remount. + render(); + await waitFor(() => expect(toggle()).toHaveAttribute('aria-expanded', 'false')); + }); + + it("does not carry one thread's choice to another", async () => { + store.set(TODO_CARD_OPEN_KEY, JSON.stringify({ t1: false })); + render(); + await act(async () => {}); + expect(toggle()).toHaveAttribute('aria-expanded', 'true'); + }); +}); diff --git a/app/src/features/conversations/aui/useSubagentElapsed.test.ts b/app/src/features/conversations/aui/useSubagentElapsed.test.ts new file mode 100644 index 00000000000..82d2b8980a0 --- /dev/null +++ b/app/src/features/conversations/aui/useSubagentElapsed.test.ts @@ -0,0 +1,39 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resetSubagentElapsedAnchors, useSubagentElapsed } from './useSubagentElapsed'; + +describe('useSubagentElapsed', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 9, 7, 12, 0, 0)); + resetSubagentElapsedAnchors(); + }); + afterEach(() => vi.useRealTimers()); + + it('ticks every second while running and keeps its anchor across remounts', () => { + const first = renderHook(() => useSubagentElapsed('sub-1', true, undefined)); + expect(first.result.current).toBe(0); + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(first.result.current).toBe(3000); + first.unmount(); + + act(() => { + vi.advanceTimersByTime(2000); + }); + const second = renderHook(() => useSubagentElapsed('sub-1', true, undefined)); + expect(second.result.current).toBe(5000); + }); + + it('reports the settled duration once finished', () => { + const { result } = renderHook(() => useSubagentElapsed('sub-2', false, 64_000)); + expect(result.current).toBe(64_000); + }); + + it('is unknown while running without a task id', () => { + const { result } = renderHook(() => useSubagentElapsed(undefined, true, undefined)); + expect(result.current).toBeUndefined(); + }); +}); diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index ffaf1ccd076..1d4c21ad85e 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -1541,6 +1541,21 @@ const messages: TranslationMap = { 'chat.failedToLoadMessages': 'Gagal memuat pesan', 'chat.thinkingIteration': 'Berpikir... ({n})', 'chat.thinkingDots': 'Berpikir...', + 'chat.status.thinkingElapsed': 'Berpikir · {elapsed}', + 'chat.status.waitingApproval': 'Menunggu persetujuan Anda', + 'chat.status.waitingReview': 'Menunggu tinjauan Anda', + 'chat.status.waitingAnswer': 'Menunggu jawaban Anda', + 'chat.status.waitingOnSubagent': 'Menunggu {name}', + 'chat.status.waitingOnSubagents': 'Menunggu {count} subagen', + 'chat.status.quietFor': 'Diam selama {duration}.', + 'chat.status.stop': 'Hentikan', + 'chat.tools.callOne': '{count} panggilan alat', + 'chat.tools.callOther': '{count} panggilan alat', + 'chat.tools.reasoningWithCalls': 'Penalaran · {calls}', + 'chat.tools.reasoning': 'Penalaran', + 'chat.tools.activity': 'Aktivitas', + 'chat.tools.workedFor': 'Bekerja selama {duration} · {calls}', + 'chat.elicitation.declineReply': 'Saya memilih tidak menjawab itu. Silakan lanjutkan tanpanya.', 'chat.upgrade': 'Tingkatkan', 'chat.weeklyLimitHit': 'Anda telah mencapai batas mingguan.', 'chat.resets': 'Reset', From 33e54384ffb1e134c330b233b8e0fd10a1db35d3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:23:28 +0300 Subject: [PATCH 085/126] feat(i18n): add bengali relative time strings Bengali translations were added for the relative time helper so that timestamps render in Bengali. The relative time utility now accepts a locale so callers can request localized output, and tests cover the new behaviour. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/bn.ts | 15 ++++++++++ app/src/utils/relativeTime.test.ts | 37 ++++++++++++++++++++++++ app/src/utils/relativeTime.ts | 45 ++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 app/src/utils/relativeTime.test.ts create mode 100644 app/src/utils/relativeTime.ts diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index eb739e4faee..db6dc4a0066 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -1524,6 +1524,21 @@ const messages: TranslationMap = { 'chat.failedToLoadMessages': 'বার্তা লোড করতে ব্যর্থ', 'chat.thinkingIteration': 'ভাবছে... ({n})', 'chat.thinkingDots': 'ভাবছে...', + 'chat.status.thinkingElapsed': 'ভাবছে · {elapsed}', + 'chat.status.waitingApproval': 'আপনার অনুমোদনের অপেক্ষায়', + 'chat.status.waitingReview': 'আপনার পর্যালোচনার অপেক্ষায়', + 'chat.status.waitingAnswer': 'আপনার উত্তরের অপেক্ষায়', + 'chat.status.waitingOnSubagent': '{name}-এর অপেক্ষায়', + 'chat.status.waitingOnSubagents': '{count}টি সাব-এজেন্টের অপেক্ষায়', + 'chat.status.quietFor': '{duration} ধরে নিষ্ক্রিয়।', + 'chat.status.stop': 'থামান', + 'chat.tools.callOne': '{count}টি টুল কল', + 'chat.tools.callOther': '{count}টি টুল কল', + 'chat.tools.reasoningWithCalls': 'যুক্তি · {calls}', + 'chat.tools.reasoning': 'যুক্তি', + 'chat.tools.activity': 'কার্যকলাপ', + 'chat.tools.workedFor': '{duration} কাজ করেছে · {calls}', + 'chat.elicitation.declineReply': 'আমি এর উত্তর দিতে চাই না। এটি ছাড়াই চালিয়ে যান।', 'chat.upgrade': 'আপগ্রেড করুন', 'chat.weeklyLimitHit': 'আপনি আপনার সাপ্তাহিক সীমায় পৌঁছেছেন।', 'chat.resets': 'রিসেট হবে', diff --git a/app/src/utils/relativeTime.test.ts b/app/src/utils/relativeTime.test.ts new file mode 100644 index 00000000000..920ce8572a4 --- /dev/null +++ b/app/src/utils/relativeTime.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { fullTimestamp, relativeTime } from './relativeTime'; + +const NOW = new Date(2026, 9, 7, 15, 0, 0); + +function ago(ms: number) { + return new Date(NOW.getTime() - ms); +} + +describe('relativeTime', () => { + it('reads the last minute, and a slightly-ahead clock, as just now', () => { + expect(relativeTime(ago(30_000), NOW)).toEqual({ kind: 'justNow' }); + expect(relativeTime(ago(-60_000), NOW)).toEqual({ kind: 'justNow' }); + }); + + it('counts minutes then hours within a day', () => { + expect(relativeTime(ago(5 * 60_000), NOW)).toEqual({ kind: 'minutes', count: 5 }); + expect(relativeTime(ago(3 * 3_600_000 + 10), NOW)).toEqual({ kind: 'hours', count: 3 }); + }); + + it('falls back to a short date, adding the year only when it differs', () => { + const lastWeek = relativeTime(new Date(2026, 9, 1, 9), NOW, 'en-US'); + expect(lastWeek).toEqual({ kind: 'date', label: 'Oct 1' }); + const lastYear = relativeTime(new Date(2025, 9, 1, 9), NOW, 'en-US'); + expect(lastYear).toEqual({ kind: 'date', label: 'Oct 1, 2025' }); + }); + + it('rejects an invalid date', () => { + expect(relativeTime(new Date('nope'), NOW)).toBeNull(); + expect(fullTimestamp(new Date('nope'))).toBe(''); + }); + + it('formats a full timestamp with the date', () => { + expect(fullTimestamp(new Date(2026, 9, 1, 9, 5), 'en-US')).toContain('Oct 1, 2026'); + }); +}); diff --git a/app/src/utils/relativeTime.ts b/app/src/utils/relativeTime.ts new file mode 100644 index 00000000000..58a929602da --- /dev/null +++ b/app/src/utils/relativeTime.ts @@ -0,0 +1,45 @@ +/** + * Compact message timestamps, after OpenClaw's `chat-message-timestamp`: + * "just now" under a minute (and for a clock a little ahead of ours), + * "{n}m ago" / "{n}h ago" within the day, then a short date ("Oct 6", with + * the year only when it differs from now). The full local date, time and zone + * belong in the element's `title`. + */ + +export type RelativeTime = + | { kind: 'justNow' } + | { kind: 'minutes'; count: number } + | { kind: 'hours'; count: number } + | { kind: 'date'; label: string }; + +const MINUTE_MS = 60_000; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; +/** Clock skew tolerated before a future time stops reading as "just now". */ +const FUTURE_SKEW_MS = 2 * MINUTE_MS; + +export function relativeTime(at: Date, now: Date, locale?: string): RelativeTime | null { + const time = at.getTime(); + if (Number.isNaN(time)) return null; + const delta = now.getTime() - time; + if (delta < MINUTE_MS && delta > -FUTURE_SKEW_MS) return { kind: 'justNow' }; + if (delta >= MINUTE_MS && delta < HOUR_MS) { + return { kind: 'minutes', count: Math.floor(delta / MINUTE_MS) }; + } + if (delta >= HOUR_MS && delta < DAY_MS) { + return { kind: 'hours', count: Math.floor(delta / HOUR_MS) }; + } + const options: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' }; + if (at.getFullYear() !== now.getFullYear()) options.year = 'numeric'; + return { kind: 'date', label: new Intl.DateTimeFormat(locale, options).format(at) }; +} + +/** Full local date and time with the zone, for a tooltip. */ +export function fullTimestamp(at: Date, locale?: string): string { + if (Number.isNaN(at.getTime())) return ''; + return new Intl.DateTimeFormat(locale, { + dateStyle: 'medium', + timeStyle: 'short', + timeZoneName: 'short', + } as Intl.DateTimeFormatOptions).format(at); +} From 521a9e5e6b77cb44bf5b3ef7cff80e0e0c517f19 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:23:31 +0300 Subject: [PATCH 086/126] feat(i18n): add Portuguese translations Added Portuguese locale strings so the app can be used in Portuguese. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/pt.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index ae52deb36e9..d93c2dc04e3 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -1557,6 +1557,21 @@ const messages: TranslationMap = { 'chat.failedToLoadMessages': 'Falha ao carregar mensagens', 'chat.thinkingIteration': 'Pensando... ({n})', 'chat.thinkingDots': 'Pensando...', + 'chat.status.thinkingElapsed': 'Pensando · {elapsed}', + 'chat.status.waitingApproval': 'Aguardando sua aprovação', + 'chat.status.waitingReview': 'Aguardando sua revisão', + 'chat.status.waitingAnswer': 'Aguardando sua resposta', + 'chat.status.waitingOnSubagent': 'Aguardando {name}', + 'chat.status.waitingOnSubagents': 'Aguardando {count} subagentes', + 'chat.status.quietFor': 'Sem atividade há {duration}.', + 'chat.status.stop': 'Parar', + 'chat.tools.callOne': '{count} chamada de ferramenta', + 'chat.tools.callOther': '{count} chamadas de ferramentas', + 'chat.tools.reasoningWithCalls': 'Raciocínio · {calls}', + 'chat.tools.reasoning': 'Raciocínio', + 'chat.tools.activity': 'Atividade', + 'chat.tools.workedFor': 'Trabalhou por {duration} · {calls}', + 'chat.elicitation.declineReply': 'Prefiro não responder a isso. Continue sem essa informação.', 'chat.upgrade': 'Fazer upgrade', 'chat.weeklyLimitHit': 'Você atingiu seu limite semanal.', 'chat.resets': 'Reinicia', From 22a3ae745b3186a17e30ad5adee4731556fd5e3b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:23:34 +0300 Subject: [PATCH 087/126] docs(i18n): add Hindi translations Added Hindi strings for the app's user-facing text so the interface can be displayed in Hindi. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/hi.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 0a2bb5e389d..631a84ab37a 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -1527,6 +1527,21 @@ const messages: TranslationMap = { 'chat.failedToLoadMessages': 'मैसेज लोड नहीं हो पाए', 'chat.thinkingIteration': 'सोच रहा है... ({n})', 'chat.thinkingDots': 'सोच रहा है...', + 'chat.status.thinkingElapsed': 'सोच रहा है · {elapsed}', + 'chat.status.waitingApproval': 'आपकी स्वीकृति की प्रतीक्षा', + 'chat.status.waitingReview': 'आपकी समीक्षा की प्रतीक्षा', + 'chat.status.waitingAnswer': 'आपके उत्तर की प्रतीक्षा', + 'chat.status.waitingOnSubagent': '{name} की प्रतीक्षा', + 'chat.status.waitingOnSubagents': '{count} सब-एजेंट की प्रतीक्षा', + 'chat.status.quietFor': '{duration} से निष्क्रिय।', + 'chat.status.stop': 'रोकें', + 'chat.tools.callOne': '{count} टूल कॉल', + 'chat.tools.callOther': '{count} टूल कॉल', + 'chat.tools.reasoningWithCalls': 'तर्क · {calls}', + 'chat.tools.reasoning': 'तर्क', + 'chat.tools.activity': 'गतिविधि', + 'chat.tools.workedFor': '{duration} काम किया · {calls}', + 'chat.elicitation.declineReply': 'मैं इसका उत्तर नहीं देना चाहता। कृपया इसके बिना जारी रखें।', 'chat.upgrade': 'अपग्रेड करें', 'chat.weeklyLimitHit': 'आपकी साप्ताहिक सीमा पूरी हो गई।', 'chat.resets': 'रीसेट होता है', From 0a64f578738f190ddca8d2efdbc4490dfb1f34c4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:23:38 +0300 Subject: [PATCH 088/126] feat(assistant-ui): add Polish translations for thread component Localized the assistant thread UI strings into Polish and wired the component to use them, so the chat interface renders in Polish for Polish-speaking users. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/components/assistant-ui/thread.tsx | 34 ++++++++++++++++++++++ app/src/lib/i18n/pl.ts | 15 ++++++++++ 2 files changed, 49 insertions(+) diff --git a/app/src/components/assistant-ui/thread.tsx b/app/src/components/assistant-ui/thread.tsx index e3eb0bb3c8a..f7a4661c130 100644 --- a/app/src/components/assistant-ui/thread.tsx +++ b/app/src/components/assistant-ui/thread.tsx @@ -12,6 +12,7 @@ import { EditMessage } from '@/components/assistant-ui/elements/edit-message'; import { ErrorState } from '@/components/assistant-ui/elements/error-state'; import { Image } from '@/components/assistant-ui/elements/image'; import { MessageTiming } from '@/components/assistant-ui/elements/message-timing.aui'; +import { fullTimestamp, relativeTime } from '@/utils/relativeTime'; import { StoppedRun } from '@/components/assistant-ui/elements/stopped-run'; import { ToolFallback } from '@/components/assistant-ui/elements/tool-fallback'; import { File } from '@/components/assistant-ui/file'; @@ -1794,10 +1795,43 @@ const AssistantActionBar: FC = () => { * that element's own docstring for why it belongs inside this root. */} + ); }; +/** + * Muted relative send time beside the message actions ("5m ago"), with the + * full local date, time and zone in its tooltip. The action bar mounts on + * hover (and for the last message), so "now" is taken when it mounts rather + * than ticking on every message in a long transcript. + */ +const MessageTimestamp: FC = () => { + const { t, locale } = useT(); + const createdAt = useAuiState(s => s.message.createdAt); + const [now] = useState(() => new Date()); + if (!(createdAt instanceof Date)) return null; + const relative = relativeTime(createdAt, now, locale); + if (!relative) return null; + const label = + relative.kind === 'justNow' + ? t('chat.message.justNow') + : relative.kind === 'minutes' + ? t('chat.message.minutesAgo').replace('{count}', String(relative.count)) + : relative.kind === 'hours' + ? t('chat.message.hoursAgo').replace('{count}', String(relative.count)) + : relative.label; + return ( + + ); +}; + const UserFilePart: FileMessagePartComponent = part => (
diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 86d9aa8782a..094549a45a6 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -1547,6 +1547,21 @@ const messages: TranslationMap = { 'chat.failedToLoadMessages': 'Nie udało się wczytać wiadomości', 'chat.thinkingIteration': 'Myślę... ({n})', 'chat.thinkingDots': 'Myślę...', + 'chat.status.thinkingElapsed': 'Myślę · {elapsed}', + 'chat.status.waitingApproval': 'Czeka na Twoją zgodę', + 'chat.status.waitingReview': 'Czeka na Twoją ocenę', + 'chat.status.waitingAnswer': 'Czeka na Twoją odpowiedź', + 'chat.status.waitingOnSubagent': 'Czeka na {name}', + 'chat.status.waitingOnSubagents': 'Czeka na subagentów: {count}', + 'chat.status.quietFor': 'Cisza od {duration}.', + 'chat.status.stop': 'Zatrzymaj', + 'chat.tools.callOne': 'Wywołania narzędzi: {count}', + 'chat.tools.callOther': 'Wywołania narzędzi: {count}', + 'chat.tools.reasoningWithCalls': 'Rozumowanie · {calls}', + 'chat.tools.reasoning': 'Rozumowanie', + 'chat.tools.activity': 'Aktywność', + 'chat.tools.workedFor': 'Pracował przez {duration} · {calls}', + 'chat.elicitation.declineReply': 'Wolę na to nie odpowiadać. Kontynuuj bez tego.', 'chat.upgrade': 'Podnieś plan', 'chat.weeklyLimitHit': 'Wykorzystano przewidziany budżet cyklu.', 'chat.resets': 'Reset', From 6015a240df462096ed6e7ee4f33e4fa461806bfd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:23:42 +0300 Subject: [PATCH 089/126] =?UTF-8?q?chore:=20I=20don't=20see=20an=20actual?= =?UTF-8?q?=20diff=20in=20your=20message=20=E2=80=94=20the=20"Diff:"=20sec?= =?UTF-8?q?tion=20is=20empty,=20and=20the=20stat=20is?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Please paste the diff (or at least the stat and the changed lines) and I'll write the Conventional Commits message. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/zh-CN.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 8f76a11c63e..ad491c1f13e 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -1412,6 +1412,21 @@ const messages: TranslationMap = { 'chat.failedToLoadMessages': '加载消息失败', 'chat.thinkingIteration': '思考中...({n})', 'chat.thinkingDots': '思考中...', + 'chat.status.thinkingElapsed': '思考中 · {elapsed}', + 'chat.status.waitingApproval': '等待你的批准', + 'chat.status.waitingReview': '等待你的审阅', + 'chat.status.waitingAnswer': '等待你的回答', + 'chat.status.waitingOnSubagent': '等待 {name}', + 'chat.status.waitingOnSubagents': '等待 {count} 个子代理', + 'chat.status.quietFor': '已静默 {duration}。', + 'chat.status.stop': '停止', + 'chat.tools.callOne': '{count} 次工具调用', + 'chat.tools.callOther': '{count} 次工具调用', + 'chat.tools.reasoningWithCalls': '推理 · {calls}', + 'chat.tools.reasoning': '推理', + 'chat.tools.activity': '活动', + 'chat.tools.workedFor': '工作了 {duration} · {calls}', + 'chat.elicitation.declineReply': '我不想回答这个问题,请在没有它的情况下继续。', 'chat.upgrade': '升级', 'chat.weeklyLimitHit': '已达到每周限制。', 'chat.resets': '重置时间', From eb42f3b3978488f4e2847dbdf426e7c4f7e15ae1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:23:49 +0300 Subject: [PATCH 090/126] =?UTF-8?q?chore:=20I=20don't=20see=20a=20diff=20i?= =?UTF-8?q?n=20your=20message=20=E2=80=94=20the=20"Diff:"=20section=20is?= =?UTF-8?q?=20empty,=20and=20the=20stat=20is=20blank=20to?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Please paste the diff (or at least the stat and the changed lines) and I'll write the Conventional Commits message. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/ru.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 3d6b7bcab54..f46e3d2fc69 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -1539,6 +1539,21 @@ const messages: TranslationMap = { 'chat.failedToLoadMessages': 'Не удалось загрузить сообщения', 'chat.thinkingIteration': 'Думаю... ({n})', 'chat.thinkingDots': 'Думаю...', + 'chat.status.thinkingElapsed': 'Думаю · {elapsed}', + 'chat.status.waitingApproval': 'Ожидает вашего одобрения', + 'chat.status.waitingReview': 'Ожидает вашей проверки', + 'chat.status.waitingAnswer': 'Ожидает вашего ответа', + 'chat.status.waitingOnSubagent': 'Ожидает {name}', + 'chat.status.waitingOnSubagents': 'Ожидает субагентов: {count}', + 'chat.status.quietFor': 'Без активности {duration}.', + 'chat.status.stop': 'Остановить', + 'chat.tools.callOne': 'Вызовов инструментов: {count}', + 'chat.tools.callOther': 'Вызовов инструментов: {count}', + 'chat.tools.reasoningWithCalls': 'Рассуждение · {calls}', + 'chat.tools.reasoning': 'Рассуждение', + 'chat.tools.activity': 'Активность', + 'chat.tools.workedFor': 'Работал {duration} · {calls}', + 'chat.elicitation.declineReply': 'Я предпочту не отвечать. Продолжайте без этого.', 'chat.upgrade': 'Улучшить', 'chat.weeklyLimitHit': 'Ты достиг еженедельного лимита.', 'chat.resets': 'Сбросится', From f4478db710377d5c5d9b627f9b35c53f80c92883 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:23:59 +0300 Subject: [PATCH 091/126] feat(assistant-ui): add activity and tool group components Introduce grouped rendering for assistant activity and tool calls so related steps collapse into a single entry instead of listing every call separately. German translations were added for the new labels. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/components/assistant-ui/activity-group.tsx | 8 +++++--- app/src/components/assistant-ui/elements/tool-group.tsx | 7 +++++-- app/src/lib/i18n/de.ts | 3 ++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/src/components/assistant-ui/activity-group.tsx b/app/src/components/assistant-ui/activity-group.tsx index 367dc5aa979..56ac164168c 100644 --- a/app/src/components/assistant-ui/activity-group.tsx +++ b/app/src/components/assistant-ui/activity-group.tsx @@ -53,7 +53,8 @@ export function activityGroupLabel( .replace('{duration}', formatElapsed(workedMs)) .replace('{calls}', tools); } - if (reasoningCount > 0 && tools) return t('chat.tools.reasoningWithCalls').replace('{calls}', tools); + if (reasoningCount > 0 && tools) + return t('chat.tools.reasoningWithCalls').replace('{calls}', tools); if (reasoningCount > 0) return t('chat.tools.reasoning'); return tools ?? t('chat.tools.activity'); } @@ -103,8 +104,9 @@ export const ActivityGroup: FC> s => s.message.parts.filter(part => part?.type === 'tool-call').length ); const totalStreamTime = useAuiState( - s => (s.message.metadata as { timing?: { totalStreamTime?: number } } | undefined)?.timing - ?.totalStreamTime + s => + (s.message.metadata as { timing?: { totalStreamTime?: number } } | undefined)?.timing + ?.totalStreamTime ); const { t } = useT(); const [userOpen, setUserOpen] = useState(null); diff --git a/app/src/components/assistant-ui/elements/tool-group.tsx b/app/src/components/assistant-ui/elements/tool-group.tsx index 793222b4b2a..68f93e46c0f 100644 --- a/app/src/components/assistant-ui/elements/tool-group.tsx +++ b/app/src/components/assistant-ui/elements/tool-group.tsx @@ -20,12 +20,12 @@ * (`chat.tools.callOne` / `chat.tools.callOther`). */ import { cn } from '@/components/assistant-ui/lib/utils'; -import { useT } from '@/lib/i18n/I18nContext'; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/assistant-ui/ui/collapsible'; +import { useT } from '@/lib/i18n/I18nContext'; import { useScrollLock } from '@assistant-ui/react'; import { cva, type VariantProps } from 'class-variance-authority'; import { ChevronDownIcon, LoaderIcon } from 'lucide-react'; @@ -111,7 +111,10 @@ function ToolGroupTrigger({ const { t } = useT(); const label = labelOverride ?? - t(count === 1 ? 'chat.tools.callOne' : 'chat.tools.callOther').replace('{count}', String(count)); + t(count === 1 ? 'chat.tools.callOne' : 'chat.tools.callOther').replace( + '{count}', + String(count) + ); return ( Date: Wed, 7 Oct 2026 01:24:18 +0300 Subject: [PATCH 092/126] docs(i18n): add missing English translation strings Added several new English translation entries to the i18n catalog so newly introduced UI text resolves instead of falling back to raw keys. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/en.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 1ccf9159e73..f44a4f475da 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -5018,6 +5018,25 @@ const en: TranslationMap = { 'composer.workspace.choose': 'Choose folder…', 'composer.workspace.hint': 'Pick where this conversation works', 'composer.workspace.error': "Couldn't use that folder", + 'composer.placeholder.running': 'Queue a follow-up…', + 'composer.placeholder.waiting': 'Answer above, or type to queue…', + 'composer.queueSend': 'Queue · sends when this finishes', + 'composer.stopEsc': 'Stop (Esc)', + 'chat.message.copy': 'Copy', + 'chat.message.copyMessage': 'Copy message', + 'chat.message.refresh': 'Refresh', + 'chat.message.goodResponse': 'Good response', + 'chat.message.badResponse': 'Bad response', + 'chat.message.more': 'More', + 'chat.message.readAloud': 'Read aloud', + 'chat.message.stopReading': 'Stop reading', + 'chat.message.edit': 'Edit', + 'chat.message.scrollToBottom': 'Scroll to bottom', + 'chat.message.justNow': 'just now', + 'chat.message.minutesAgo': '{count}m ago', + 'chat.message.hoursAgo': '{count}h ago', + 'chat.message.interrupted': 'Interrupted', + 'chat.message.interruptedDetail': 'This reply was cut off before it finished.', 'composer.settings.model': 'Model', 'composer.settings.temperature': 'Temperature', 'composer.settings.chooseModel': 'Choose another model…', From 5f99c09fdb90da7eae267f103b717171a8ca9729 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:24:23 +0300 Subject: [PATCH 093/126] =?UTF-8?q?chore:=20I=20don't=20see=20a=20diff=20i?= =?UTF-8?q?n=20your=20message=20=E2=80=94=20the=20Files,=20Stat,=20and=20D?= =?UTF-8?q?iff=20sections=20are=20all=20empty.=20Coul?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/ar.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index b614612a1f3..e9d6a02e0a0 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4285,6 +4285,25 @@ const messages: TranslationMap = { 'composer.workspace.choose': 'اختر مجلدًا…', 'composer.workspace.hint': 'اختر مكان عمل هذه المحادثة', 'composer.workspace.error': 'تعذّر استخدام هذا المجلد', + 'composer.placeholder.running': 'أضف رسالة متابعة إلى قائمة الانتظار…', + 'composer.placeholder.waiting': 'أجب أعلاه، أو اكتب لإضافتها إلى قائمة الانتظار…', + 'composer.queueSend': 'قائمة الانتظار · تُرسل عند انتهاء هذا', + 'composer.stopEsc': 'إيقاف (Esc)', + 'chat.message.copy': 'نسخ', + 'chat.message.copyMessage': 'نسخ الرسالة', + 'chat.message.refresh': 'تحديث', + 'chat.message.goodResponse': 'رد جيد', + 'chat.message.badResponse': 'رد سيئ', + 'chat.message.more': 'المزيد', + 'chat.message.readAloud': 'قراءة بصوت عالٍ', + 'chat.message.stopReading': 'إيقاف القراءة', + 'chat.message.edit': 'تعديل', + 'chat.message.scrollToBottom': 'التمرير إلى الأسفل', + 'chat.message.justNow': 'الآن', + 'chat.message.minutesAgo': 'منذ {count} د', + 'chat.message.hoursAgo': 'منذ {count} س', + 'chat.message.interrupted': 'متوقف', + 'chat.message.interruptedDetail': 'انقطع هذا الرد قبل أن يكتمل.', 'composer.settings.model': 'النموذج', 'composer.settings.temperature': 'درجة الحرارة', 'composer.settings.chooseModel': 'اختر نموذجًا آخر…', From 3b77da3daabe4ac2d3d2568e93c674209d03fd4a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:24:27 +0300 Subject: [PATCH 094/126] test(conversations): cover parallel agents card rendering Adds tests for the parallel agents card in the conversations feature, exercising its rendering and interaction paths so regressions in the card's behaviour are caught. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../aui/ParallelAgentsCard.test.tsx | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 app/src/features/conversations/aui/ParallelAgentsCard.test.tsx diff --git a/app/src/features/conversations/aui/ParallelAgentsCard.test.tsx b/app/src/features/conversations/aui/ParallelAgentsCard.test.tsx new file mode 100644 index 00000000000..ec51c647cdf --- /dev/null +++ b/app/src/features/conversations/aui/ParallelAgentsCard.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import type { ToolTimelineEntry } from '../../../store/chatRuntimeSlice'; +import { ParallelAgentsCard } from './ParallelAgentsCard'; + +vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) })); +vi.mock('../../../providers/AssistantUiRuntimeProvider', () => ({ useAuiThreadId: () => 't1' })); +vi.mock('./SubagentActivityCard', () => ({ SubagentActivityCard: () => null })); + +const timeline = vi.hoisted(() => ({ entries: [] as ToolTimelineEntry[] })); +vi.mock('../../../store/hooks', () => ({ + useAppSelector: (select: (state: unknown) => unknown) => + select({ chatRuntime: { toolTimelineByThread: { t1: timeline.entries } } }), +})); + +function child(id: string, seq: number, status: string): ToolTimelineEntry { + return { + id, + name: 'subagent', + round: 1, + seq, + status: status === 'running' ? 'running' : 'success', + subagent: { taskId: id, agentId: id, status, parentCallId: 'call-p', toolCalls: [] }, + } as ToolTimelineEntry; +} + +function renderCard(status: 'running' | 'complete') { + return render( + {}} + resume={() => {}} + respondToApproval={async () => {}} + /> + ); +} + +describe('ParallelAgentsCard', () => { + it('counts complete, running and failed workers', () => { + timeline.entries = [ + child('a', 1, 'running'), + child('b', 2, 'completed'), + child('c', 3, 'failed'), + ]; + renderCard('running'); + const header = screen.getByTestId('parallel-agents-header'); + expect(header).toHaveTextContent('chat.subagents.ofTotal'); + expect(header).toHaveTextContent('chat.subagents.runningCount · chat.subagents.failedCount'); + expect(screen.queryByTestId('parallel-agents-aggregating')).toBeNull(); + }); + + it('says the parent is processing once every worker is back but the call is still open', () => { + timeline.entries = [child('a', 1, 'completed'), child('b', 2, 'completed')]; + renderCard('running'); + expect(screen.getByTestId('parallel-agents-aggregating')).toHaveTextContent( + 'chat.subagents.settled' + ); + }); + + it('drops the processing line once the call settles', () => { + timeline.entries = [child('a', 1, 'completed')]; + renderCard('complete'); + expect(screen.queryByTestId('parallel-agents-aggregating')).toBeNull(); + }); +}); From f2f5fb25608b479c850b8aaef3c4d912b06f3176 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:24:29 +0300 Subject: [PATCH 095/126] test(conversations): add coverage for agent status and stall warning Adds tests for the agent running status, stall warning, and live elapsed hook, plus a Bengali translation entry. The new cases lock in the existing behaviour of these conversation UI pieces. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../aui/AgentRunningStatus.test.tsx | 78 ++++++++++++++++++- .../conversations/aui/StallWarning.test.tsx | 43 ++++++++++ .../conversations/aui/useLiveElapsed.test.ts | 31 ++++++++ app/src/lib/i18n/bn.ts | 19 +++++ 4 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 app/src/features/conversations/aui/StallWarning.test.tsx create mode 100644 app/src/features/conversations/aui/useLiveElapsed.test.ts diff --git a/app/src/features/conversations/aui/AgentRunningStatus.test.tsx b/app/src/features/conversations/aui/AgentRunningStatus.test.tsx index ee2309e51e4..547d1fcc815 100644 --- a/app/src/features/conversations/aui/AgentRunningStatus.test.tsx +++ b/app/src/features/conversations/aui/AgentRunningStatus.test.tsx @@ -6,7 +6,7 @@ import { describe, expect, it, vi } from 'vitest'; import { AssistantUiRuntimeProvider } from '../../../providers/AssistantUiRuntimeProvider'; import chatRuntimeReducer from '../../../store/chatRuntimeSlice'; import threadReducer from '../../../store/threadSlice'; -import { AgentRunningStatus } from './AgentRunningStatus'; +import { AgentRunningStatus, hasPendingClarification, waitingOnUser } from './AgentRunningStatus'; vi.mock('../../../services/api/threadApi', () => ({ threadApi: { @@ -24,10 +24,15 @@ vi.mock('../../../services/api/threadApi', () => ({ const THREAD_ID = 't-status'; -function buildStore() { +function buildStore(chatRuntime?: Record) { + const reducer = combineReducers({ thread: threadReducer, chatRuntime: chatRuntimeReducer }); + const runtime = chatRuntime + ? { ...(reducer(undefined, { type: '@@init' }).chatRuntime as object), ...chatRuntime } + : undefined; return configureStore({ - reducer: combineReducers({ thread: threadReducer, chatRuntime: chatRuntimeReducer }), + reducer, preloadedState: { + ...(runtime ? { chatRuntime: runtime } : {}), thread: { threads: [], selectedThreadId: THREAD_ID, @@ -62,3 +67,70 @@ describe('AgentRunningStatus', () => { expect(screen.queryByTestId('agent-running-status-tasks')).not.toBeInTheDocument(); }); }); + +function renderStatus(chatRuntime?: Record) { + return render( + + + + + + ); +} + +describe('AgentRunningStatus waiting states', () => { + it('says it is waiting for approval instead of thinking', () => { + renderStatus({ + pendingApprovalByThread: { + [THREAD_ID]: { requestId: 'r1', toolName: 'shell', message: 'run', expiresAt: null }, + }, + }); + const line = screen.getByTestId('agent-running-status-waiting'); + expect(line).toHaveAttribute('data-waiting', 'approval'); + expect(line).toHaveTextContent('Waiting for your approval'); + expect(screen.queryByTestId('agent-running-status-thinking')).not.toBeInTheDocument(); + }); + + it('says it is waiting for a plan review', () => { + renderStatus({ pendingPlanReviewByThread: { [THREAD_ID]: { requestId: 'p1' } } }); + expect(screen.getByTestId('agent-running-status-waiting')).toHaveAttribute( + 'data-waiting', + 'review' + ); + }); +}); + +describe('waitingOnUser', () => { + it('ranks approval over review over answer', () => { + expect(waitingOnUser({ approval: true, review: true, answer: true })).toBe('approval'); + expect(waitingOnUser({ approval: false, review: true, answer: true })).toBe('review'); + expect(waitingOnUser({ approval: false, review: false, answer: true })).toBe('answer'); + expect(waitingOnUser({ approval: false, review: false, answer: false })).toBeNull(); + }); +}); + +describe('hasPendingClarification', () => { + const ask = (result?: unknown) => ({ + type: 'tool-call', + toolName: 'ask_user_clarification', + result, + }); + + it('is true only for an unanswered question on the newest assistant message', () => { + expect(hasPendingClarification([{ role: 'assistant', content: [ask()] }])).toBe(true); + expect(hasPendingClarification([{ role: 'assistant', content: [ask('ok')] }])).toBe(false); + expect( + hasPendingClarification([ + { role: 'assistant', content: [ask()] }, + { role: 'assistant', content: [{ type: 'text' }] }, + ]) + ).toBe(false); + expect( + hasPendingClarification([ + { role: 'assistant', content: [ask()] }, + { role: 'user', content: [{ type: 'text' }] }, + ]) + ).toBe(true); + expect(hasPendingClarification([])).toBe(false); + }); +}); diff --git a/app/src/features/conversations/aui/StallWarning.test.tsx b/app/src/features/conversations/aui/StallWarning.test.tsx new file mode 100644 index 00000000000..74cb7cc8db8 --- /dev/null +++ b/app/src/features/conversations/aui/StallWarning.test.tsx @@ -0,0 +1,43 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StallWarning } from './StallWarning'; + +vi.mock('../../../lib/i18n/I18nContext', () => ({ + useT: () => ({ + t: (key: string) => (key === 'chat.status.quietFor' ? 'Quiet for {duration}.' : key), + }), +})); + +describe('StallWarning', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 9, 7, 12, 0, 0)); + }); + afterEach(() => vi.useRealTimers()); + + it('shows the phase copy, a ticking quiet duration and a Stop action', () => { + const onStop = vi.fn(); + render(); + + const warning = screen.getByTestId('chat-stall-warning'); + expect(warning).toHaveAttribute('data-chat-stall-phase', 'thinking'); + expect(warning).toHaveTextContent('chat.stallWarning.thinking'); + expect(screen.getByTestId('chat-stall-quiet')).toHaveTextContent('Quiet for 2m 0s.'); + + act(() => { + vi.advanceTimersByTime(5_000); + }); + expect(screen.getByTestId('chat-stall-quiet')).toHaveTextContent('Quiet for 2m 5s.'); + + fireEvent.click(screen.getByTestId('chat-stall-stop')); + expect(onStop).toHaveBeenCalledTimes(1); + }); + + it('uses the working copy for a tool phase', () => { + render(); + expect(screen.getByTestId('chat-stall-warning')).toHaveTextContent( + 'chat.stallWarning.working' + ); + }); +}); diff --git a/app/src/features/conversations/aui/useLiveElapsed.test.ts b/app/src/features/conversations/aui/useLiveElapsed.test.ts new file mode 100644 index 00000000000..6f5f4bb3ba2 --- /dev/null +++ b/app/src/features/conversations/aui/useLiveElapsed.test.ts @@ -0,0 +1,31 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useLiveElapsed, useRunningSince } from './useLiveElapsed'; + +describe('useLiveElapsed / useRunningSince', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 9, 7, 12, 0, 0)); + }); + afterEach(() => vi.useRealTimers()); + + it('ticks while running and stops when it settles', () => { + const { result, rerender } = renderHook( + ({ running }) => useLiveElapsed(useRunningSince(running), running), + { initialProps: { running: true } } + ); + expect(result.current).toBe(0); + act(() => { + vi.advanceTimersByTime(3_000); + }); + expect(result.current).toBe(3_000); + rerender({ running: false }); + expect(result.current).toBeUndefined(); + }); + + it('is undefined without a start time', () => { + const { result } = renderHook(() => useLiveElapsed(undefined, true)); + expect(result.current).toBeUndefined(); + }); +}); diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index db6dc4a0066..42af60137f5 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4364,6 +4364,25 @@ const messages: TranslationMap = { 'composer.workspace.choose': 'ফোল্ডার বেছে নিন…', 'composer.workspace.hint': 'এই কথোপকথন কোথায় কাজ করবে তা বেছে নিন', 'composer.workspace.error': 'ওই ফোল্ডারটি ব্যবহার করা যায়নি', + 'composer.placeholder.running': 'একটি ফলো-আপ সারিতে রাখুন…', + 'composer.placeholder.waiting': 'উপরে উত্তর দিন, অথবা সারিতে রাখতে লিখুন…', + 'composer.queueSend': 'সারি · এটি শেষ হলে পাঠানো হবে', + 'composer.stopEsc': 'থামান (Esc)', + 'chat.message.copy': 'কপি', + 'chat.message.copyMessage': 'বার্তা কপি করুন', + 'chat.message.refresh': 'রিফ্রেশ', + 'chat.message.goodResponse': 'ভালো উত্তর', + 'chat.message.badResponse': 'খারাপ উত্তর', + 'chat.message.more': 'আরও', + 'chat.message.readAloud': 'জোরে পড়ুন', + 'chat.message.stopReading': 'পড়া থামান', + 'chat.message.edit': 'সম্পাদনা', + 'chat.message.scrollToBottom': 'নিচে স্ক্রল করুন', + 'chat.message.justNow': 'এইমাত্র', + 'chat.message.minutesAgo': '{count} মিনিট আগে', + 'chat.message.hoursAgo': '{count} ঘণ্টা আগে', + 'chat.message.interrupted': 'বাধাপ্রাপ্ত', + 'chat.message.interruptedDetail': 'এই উত্তরটি শেষ হওয়ার আগেই কেটে গেছে।', 'composer.settings.model': 'মডেল', 'composer.settings.temperature': 'টেম্পারেচার', 'composer.settings.chooseModel': 'অন্য মডেল বেছে নিন…', From f316fa108250599c8e5c66237b8c37b3b8f87931 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:24:34 +0300 Subject: [PATCH 096/126] docs(i18n): add German translations Added German translations for the new interface strings so the app displays correctly for German-speaking users. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/de.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 0001db85ca2..8fce2f80f7c 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4473,6 +4473,25 @@ const messages: TranslationMap = { 'composer.workspace.choose': 'Ordner auswählen…', 'composer.workspace.hint': 'Wähle, wo diese Unterhaltung arbeitet', 'composer.workspace.error': 'Dieser Ordner konnte nicht verwendet werden', + 'composer.placeholder.running': 'Folgenachricht einreihen…', + 'composer.placeholder.waiting': 'Oben antworten oder zum Einreihen tippen…', + 'composer.queueSend': 'Einreihen · wird gesendet, wenn dies fertig ist', + 'composer.stopEsc': 'Stoppen (Esc)', + 'chat.message.copy': 'Kopieren', + 'chat.message.copyMessage': 'Nachricht kopieren', + 'chat.message.refresh': 'Neu generieren', + 'chat.message.goodResponse': 'Gute Antwort', + 'chat.message.badResponse': 'Schlechte Antwort', + 'chat.message.more': 'Mehr', + 'chat.message.readAloud': 'Vorlesen', + 'chat.message.stopReading': 'Vorlesen beenden', + 'chat.message.edit': 'Bearbeiten', + 'chat.message.scrollToBottom': 'Nach unten scrollen', + 'chat.message.justNow': 'gerade eben', + 'chat.message.minutesAgo': 'vor {count} Min.', + 'chat.message.hoursAgo': 'vor {count} Std.', + 'chat.message.interrupted': 'Unterbrochen', + 'chat.message.interruptedDetail': 'Diese Antwort wurde abgebrochen, bevor sie fertig war.', 'composer.settings.model': 'Modell', 'composer.settings.temperature': 'Temperatur', 'composer.settings.chooseModel': 'Anderes Modell wählen…', From ca4ef89f6765fb4014be2e100c516f6c941a7497 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:24:38 +0300 Subject: [PATCH 097/126] docs(i18n): add Spanish translations Add Spanish locale strings covering the app's user-facing text so the interface can be displayed in Spanish. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/es.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 06591fd0296..e9bc81f2ee7 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4436,6 +4436,25 @@ const messages: TranslationMap = { 'composer.workspace.choose': 'Elegir carpeta…', 'composer.workspace.hint': 'Elige dónde trabaja esta conversación', 'composer.workspace.error': 'No se pudo usar esa carpeta', + 'composer.placeholder.running': 'Pon en cola un seguimiento…', + 'composer.placeholder.waiting': 'Responde arriba o escribe para poner en cola…', + 'composer.queueSend': 'En cola · se envía cuando esto termine', + 'composer.stopEsc': 'Detener (Esc)', + 'chat.message.copy': 'Copiar', + 'chat.message.copyMessage': 'Copiar mensaje', + 'chat.message.refresh': 'Regenerar', + 'chat.message.goodResponse': 'Buena respuesta', + 'chat.message.badResponse': 'Mala respuesta', + 'chat.message.more': 'Más', + 'chat.message.readAloud': 'Leer en voz alta', + 'chat.message.stopReading': 'Dejar de leer', + 'chat.message.edit': 'Editar', + 'chat.message.scrollToBottom': 'Desplazarse al final', + 'chat.message.justNow': 'ahora mismo', + 'chat.message.minutesAgo': 'hace {count} min', + 'chat.message.hoursAgo': 'hace {count} h', + 'chat.message.interrupted': 'Interrumpida', + 'chat.message.interruptedDetail': 'Esta respuesta se cortó antes de terminar.', 'composer.settings.model': 'Modelo', 'composer.settings.temperature': 'Temperatura', 'composer.settings.chooseModel': 'Elegir otro modelo…', From 21db95211284f7bbecb9c33d99ee147779017d8f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:24:43 +0300 Subject: [PATCH 098/126] docs(i18n): add French translations for new UI strings Extend the French locale with translations for the newly added interface strings so the app stays fully localized. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/fr.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 06c9225b263..488587f860a 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4453,6 +4453,25 @@ const messages: TranslationMap = { 'composer.workspace.choose': 'Choisir un dossier…', 'composer.workspace.hint': 'Choisissez où travaille cette conversation', 'composer.workspace.error': "Impossible d'utiliser ce dossier", + 'composer.placeholder.running': 'Mettre une relance en file…', + 'composer.placeholder.waiting': 'Répondez ci-dessus, ou tapez pour mettre en file…', + 'composer.queueSend': 'En file · envoyé quand ceci se termine', + 'composer.stopEsc': 'Arrêter (Échap)', + 'chat.message.copy': 'Copier', + 'chat.message.copyMessage': 'Copier le message', + 'chat.message.refresh': 'Régénérer', + 'chat.message.goodResponse': 'Bonne réponse', + 'chat.message.badResponse': 'Mauvaise réponse', + 'chat.message.more': 'Plus', + 'chat.message.readAloud': 'Lire à voix haute', + 'chat.message.stopReading': 'Arrêter la lecture', + 'chat.message.edit': 'Modifier', + 'chat.message.scrollToBottom': 'Défiler vers le bas', + 'chat.message.justNow': "à l'instant", + 'chat.message.minutesAgo': 'il y a {count} min', + 'chat.message.hoursAgo': 'il y a {count} h', + 'chat.message.interrupted': 'Interrompue', + 'chat.message.interruptedDetail': 'Cette réponse a été coupée avant la fin.', 'composer.settings.model': 'Modèle', 'composer.settings.temperature': 'Température', 'composer.settings.chooseModel': 'Choisir un autre modèle…', From a08e4141c87c7b74704e3f5d4ece70201af43f0b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:24:48 +0300 Subject: [PATCH 099/126] feat(i18n): add hindi translations for conversation ui Added Hindi strings covering the conversation UI, including stall warnings, tool call rendering, and elicitation prompts, so the interface is localized for Hindi-speaking users. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/aui/StallWarning.test.tsx | 4 +-- .../components/AssistantUiToolCall.test.tsx | 30 +++++++++++++++- .../ChatToolParts.elicitation.test.tsx | 34 +++++++++++++++++++ app/src/lib/i18n/hi.ts | 19 +++++++++++ 4 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 app/src/features/conversations/components/ChatToolParts.elicitation.test.tsx diff --git a/app/src/features/conversations/aui/StallWarning.test.tsx b/app/src/features/conversations/aui/StallWarning.test.tsx index 74cb7cc8db8..0c9522a8889 100644 --- a/app/src/features/conversations/aui/StallWarning.test.tsx +++ b/app/src/features/conversations/aui/StallWarning.test.tsx @@ -36,8 +36,6 @@ describe('StallWarning', () => { it('uses the working copy for a tool phase', () => { render(); - expect(screen.getByTestId('chat-stall-warning')).toHaveTextContent( - 'chat.stallWarning.working' - ); + expect(screen.getByTestId('chat-stall-warning')).toHaveTextContent('chat.stallWarning.working'); }); }); diff --git a/app/src/features/conversations/components/AssistantUiToolCall.test.tsx b/app/src/features/conversations/components/AssistantUiToolCall.test.tsx index 3e3622d1b1a..bb96543029a 100644 --- a/app/src/features/conversations/components/AssistantUiToolCall.test.tsx +++ b/app/src/features/conversations/components/AssistantUiToolCall.test.tsx @@ -1,6 +1,7 @@ import { render, screen, within } from '@testing-library/react'; +import { act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { AssistantUiToolCallCard, formatElapsed } from './AssistantUiToolCall'; @@ -111,6 +112,33 @@ describe('AssistantUiToolCallCard', () => { expect(screen.queryByTestId('web-search-balance-hint')).toBeNull(); }); + it('shows a live elapsed clock while a tool runs', () => { + vi.useFakeTimers(); + try { + const { rerender } = render( + + ); + expect(screen.queryByTestId('tool-call-elapsed-live')).toBeNull(); + act(() => { + vi.advanceTimersByTime(2_000); + }); + expect(screen.getByTestId('tool-call-elapsed-live')).toHaveTextContent('2.0s'); + rerender( + + ); + expect(screen.queryByTestId('tool-call-elapsed-live')).toBeNull(); + expect(screen.getByTestId('tool-call-elapsed')).toHaveTextContent('2.3s'); + } finally { + vi.useRealTimers(); + } + }); + it('swaps the label tense as the call settles', () => { const { rerender } = render( diff --git a/app/src/features/conversations/components/ChatToolParts.elicitation.test.tsx b/app/src/features/conversations/components/ChatToolParts.elicitation.test.tsx new file mode 100644 index 00000000000..df53dcf91f9 --- /dev/null +++ b/app/src/features/conversations/components/ChatToolParts.elicitation.test.tsx @@ -0,0 +1,34 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { ChatToolFallback } from './ChatToolParts'; + +const append = vi.fn(); +vi.mock('@assistant-ui/react', async importOriginal => ({ + ...(await importOriginal()), + useAui: () => ({ thread: { append } }), +})); + +describe('ChatToolParts ask_user_clarification', () => { + it('declining sends a reply so the parked turn can continue', () => { + render( + {}} + resume={() => {}} + respondToApproval={async () => {}} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: /decline/i })); + expect(append).toHaveBeenCalledWith({ + role: 'user', + content: [{ type: 'text', text: "I'd rather not answer that. Please continue without it." }], + }); + }); +}); diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 631a84ab37a..55522bf31f6 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4363,6 +4363,25 @@ const messages: TranslationMap = { 'composer.workspace.choose': 'फ़ोल्डर चुनें…', 'composer.workspace.hint': 'चुनें कि यह बातचीत कहाँ काम करेगी', 'composer.workspace.error': 'उस फ़ोल्डर का उपयोग नहीं हो सका', + 'composer.placeholder.running': 'एक फ़ॉलो-अप कतार में डालें…', + 'composer.placeholder.waiting': 'ऊपर जवाब दें, या कतार में डालने के लिए लिखें…', + 'composer.queueSend': 'कतार · यह पूरा होने पर भेजा जाएगा', + 'composer.stopEsc': 'रोकें (Esc)', + 'chat.message.copy': 'कॉपी करें', + 'chat.message.copyMessage': 'संदेश कॉपी करें', + 'chat.message.refresh': 'फिर से बनाएँ', + 'chat.message.goodResponse': 'अच्छा जवाब', + 'chat.message.badResponse': 'खराब जवाब', + 'chat.message.more': 'और', + 'chat.message.readAloud': 'ज़ोर से पढ़ें', + 'chat.message.stopReading': 'पढ़ना बंद करें', + 'chat.message.edit': 'संपादित करें', + 'chat.message.scrollToBottom': 'नीचे स्क्रॉल करें', + 'chat.message.justNow': 'अभी-अभी', + 'chat.message.minutesAgo': '{count} मिनट पहले', + 'chat.message.hoursAgo': '{count} घंटे पहले', + 'chat.message.interrupted': 'बाधित', + 'chat.message.interruptedDetail': 'यह जवाब पूरा होने से पहले ही कट गया।', 'composer.settings.model': 'मॉडल', 'composer.settings.temperature': 'टेम्परेचर', 'composer.settings.chooseModel': 'दूसरा मॉडल चुनें…', From 9b699f7546bbcb48ab1bf01827266bc373bc1eb8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:24:52 +0300 Subject: [PATCH 100/126] feat(i18n): add Indonesian translations Added an Indonesian locale file with translations for the app's UI strings, extending language support to Indonesian users. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/id.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 1d4c21ad85e..c1042a8b4d9 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4391,6 +4391,25 @@ const messages: TranslationMap = { 'composer.workspace.choose': 'Pilih folder…', 'composer.workspace.hint': 'Pilih tempat percakapan ini bekerja', 'composer.workspace.error': 'Tidak dapat menggunakan folder itu', + 'composer.placeholder.running': 'Antrekan pesan lanjutan…', + 'composer.placeholder.waiting': 'Jawab di atas, atau ketik untuk mengantre…', + 'composer.queueSend': 'Antre · dikirim setelah ini selesai', + 'composer.stopEsc': 'Hentikan (Esc)', + 'chat.message.copy': 'Salin', + 'chat.message.copyMessage': 'Salin pesan', + 'chat.message.refresh': 'Buat ulang', + 'chat.message.goodResponse': 'Jawaban bagus', + 'chat.message.badResponse': 'Jawaban buruk', + 'chat.message.more': 'Lainnya', + 'chat.message.readAloud': 'Bacakan', + 'chat.message.stopReading': 'Berhenti membacakan', + 'chat.message.edit': 'Edit', + 'chat.message.scrollToBottom': 'Gulir ke bawah', + 'chat.message.justNow': 'baru saja', + 'chat.message.minutesAgo': '{count} mnt lalu', + 'chat.message.hoursAgo': '{count} jam lalu', + 'chat.message.interrupted': 'Terputus', + 'chat.message.interruptedDetail': 'Jawaban ini terpotong sebelum selesai.', 'composer.settings.model': 'Model', 'composer.settings.temperature': 'Suhu', 'composer.settings.chooseModel': 'Pilih model lain…', From 28da8f646e9d4ce5791d0f07f8fb9c40a94a4460 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:24:57 +0300 Subject: [PATCH 101/126] docs(i18n): add Italian translations Added Italian locale strings to the i18n catalog so the app can display Italian text. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/it.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 625ed73845e..a1e0f7f5920 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4429,6 +4429,25 @@ const messages: TranslationMap = { 'composer.workspace.choose': 'Scegli cartella…', 'composer.workspace.hint': 'Scegli dove lavora questa conversazione', 'composer.workspace.error': 'Impossibile usare quella cartella', + 'composer.placeholder.running': 'Metti in coda un seguito…', + 'composer.placeholder.waiting': 'Rispondi sopra, o scrivi per mettere in coda…', + 'composer.queueSend': 'In coda · inviato al termine', + 'composer.stopEsc': 'Interrompi (Esc)', + 'chat.message.copy': 'Copia', + 'chat.message.copyMessage': 'Copia messaggio', + 'chat.message.refresh': 'Rigenera', + 'chat.message.goodResponse': 'Risposta buona', + 'chat.message.badResponse': 'Risposta pessima', + 'chat.message.more': 'Altro', + 'chat.message.readAloud': 'Leggi ad alta voce', + 'chat.message.stopReading': 'Interrompi lettura', + 'chat.message.edit': 'Modifica', + 'chat.message.scrollToBottom': 'Scorri in fondo', + 'chat.message.justNow': 'adesso', + 'chat.message.minutesAgo': '{count} min fa', + 'chat.message.hoursAgo': '{count} h fa', + 'chat.message.interrupted': 'Interrotta', + 'chat.message.interruptedDetail': 'Questa risposta è stata interrotta prima di finire.', 'composer.settings.model': 'Modello', 'composer.settings.temperature': 'Temperatura', 'composer.settings.chooseModel': 'Scegli un altro modello…', From c2d7f2996ba6abd0565983abad1a6bf0394066b4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:25:01 +0300 Subject: [PATCH 102/126] feat(i18n): add korean translations Added Korean locale strings to the i18n module so the app can display Korean text. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/ko.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index af2876a760f..711924747ee 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4328,6 +4328,25 @@ const messages: TranslationMap = { 'composer.workspace.choose': '폴더 선택…', 'composer.workspace.hint': '이 대화가 작업할 위치를 선택하세요', 'composer.workspace.error': '해당 폴더를 사용할 수 없습니다', + 'composer.placeholder.running': '후속 메시지를 대기열에 추가…', + 'composer.placeholder.waiting': '위에서 답하거나, 입력해 대기열에 추가…', + 'composer.queueSend': '대기열 · 완료되면 전송', + 'composer.stopEsc': '중지 (Esc)', + 'chat.message.copy': '복사', + 'chat.message.copyMessage': '메시지 복사', + 'chat.message.refresh': '다시 생성', + 'chat.message.goodResponse': '좋은 응답', + 'chat.message.badResponse': '나쁜 응답', + 'chat.message.more': '더 보기', + 'chat.message.readAloud': '소리 내어 읽기', + 'chat.message.stopReading': '읽기 중지', + 'chat.message.edit': '편집', + 'chat.message.scrollToBottom': '맨 아래로 스크롤', + 'chat.message.justNow': '방금', + 'chat.message.minutesAgo': '{count}분 전', + 'chat.message.hoursAgo': '{count}시간 전', + 'chat.message.interrupted': '중단됨', + 'chat.message.interruptedDetail': '이 응답은 끝나기 전에 끊겼습니다.', 'composer.settings.model': '모델', 'composer.settings.temperature': '온도', 'composer.settings.chooseModel': '다른 모델 선택…', From 48f98008963b9554c417fb2ff235953e20ce4f87 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:25:06 +0300 Subject: [PATCH 103/126] feat(i18n): add Polish translations Added Polish locale strings to the i18n module so the app can display Polish text. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/pl.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 094549a45a6..21667e114b0 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4419,6 +4419,25 @@ const messages: TranslationMap = { 'composer.workspace.choose': 'Wybierz folder…', 'composer.workspace.hint': 'Wybierz, gdzie pracuje ta rozmowa', 'composer.workspace.error': 'Nie można użyć tego folderu', + 'composer.placeholder.running': 'Dodaj wiadomość do kolejki…', + 'composer.placeholder.waiting': 'Odpowiedz powyżej lub pisz, by dodać do kolejki…', + 'composer.queueSend': 'Kolejka · wyślemy po zakończeniu', + 'composer.stopEsc': 'Zatrzymaj (Esc)', + 'chat.message.copy': 'Kopiuj', + 'chat.message.copyMessage': 'Kopiuj wiadomość', + 'chat.message.refresh': 'Wygeneruj ponownie', + 'chat.message.goodResponse': 'Dobra odpowiedź', + 'chat.message.badResponse': 'Zła odpowiedź', + 'chat.message.more': 'Więcej', + 'chat.message.readAloud': 'Czytaj na głos', + 'chat.message.stopReading': 'Zatrzymaj czytanie', + 'chat.message.edit': 'Edytuj', + 'chat.message.scrollToBottom': 'Przewiń na dół', + 'chat.message.justNow': 'przed chwilą', + 'chat.message.minutesAgo': '{count} min temu', + 'chat.message.hoursAgo': '{count} godz. temu', + 'chat.message.interrupted': 'Przerwano', + 'chat.message.interruptedDetail': 'Ta odpowiedź została ucięta przed końcem.', 'composer.settings.model': 'Model', 'composer.settings.temperature': 'Temperatura', 'composer.settings.chooseModel': 'Wybierz inny model…', From 796bdb79b6cbd6623126ba9d5c0ac4195a1f05c9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:25:10 +0300 Subject: [PATCH 104/126] feat(i18n): add Portuguese translations Added Portuguese locale strings so the app can be used in Portuguese. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/pt.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index d93c2dc04e3..2c5f25b28a4 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4417,6 +4417,25 @@ const messages: TranslationMap = { 'composer.workspace.choose': 'Escolher pasta…', 'composer.workspace.hint': 'Escolha onde esta conversa trabalha', 'composer.workspace.error': 'Não foi possível usar essa pasta', + 'composer.placeholder.running': 'Coloque um acompanhamento na fila…', + 'composer.placeholder.waiting': 'Responda acima ou digite para colocar na fila…', + 'composer.queueSend': 'Na fila · enviado quando isto terminar', + 'composer.stopEsc': 'Parar (Esc)', + 'chat.message.copy': 'Copiar', + 'chat.message.copyMessage': 'Copiar mensagem', + 'chat.message.refresh': 'Gerar novamente', + 'chat.message.goodResponse': 'Boa resposta', + 'chat.message.badResponse': 'Resposta ruim', + 'chat.message.more': 'Mais', + 'chat.message.readAloud': 'Ler em voz alta', + 'chat.message.stopReading': 'Parar leitura', + 'chat.message.edit': 'Editar', + 'chat.message.scrollToBottom': 'Rolar até o fim', + 'chat.message.justNow': 'agora mesmo', + 'chat.message.minutesAgo': 'há {count} min', + 'chat.message.hoursAgo': 'há {count} h', + 'chat.message.interrupted': 'Interrompida', + 'chat.message.interruptedDetail': 'Esta resposta foi cortada antes de terminar.', 'composer.settings.model': 'Modelo', 'composer.settings.temperature': 'Temperatura', 'composer.settings.chooseModel': 'Escolher outro modelo…', From 91d268a6b5cb86adcbb9a276c0921436d8d5e4be Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:25:13 +0300 Subject: [PATCH 105/126] =?UTF-8?q?chore:=20I=20don't=20see=20an=20actual?= =?UTF-8?q?=20diff=20in=20your=20message=20=E2=80=94=20the=20"Diff:"=20sec?= =?UTF-8?q?tion=20is=20empty,=20and=20the=20stat=20is?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Please paste the diff (or at least the stat and the changed lines) and I'll write the Conventional Commits message. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/zh-CN.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index ad491c1f13e..6264bea4852 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2016,7 +2016,7 @@ const messages: TranslationMap = { 'conversations.composer.mention.files': '文件', 'conversations.todos.title': '待办', 'chat.todos.completed': '已完成', - 'chat.todos.ofTotal': '{done} / {total}', + 'chat.todos.ofTotal': '已完成 {done},共 {total}', 'chat.todos.receipt': '进度已更新 — {done}/{total}', 'chat.subagents.ranFor': '运行了 {duration}', 'chat.subagents.ofTotal': '{complete} / {total}', From a3cc2dab242c8b9777d9a70b5eb5e68598dcd184 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:25:15 +0300 Subject: [PATCH 106/126] feat(i18n): add Russian and Chinese translations Added Russian and Simplified Chinese locale files so the app can be localized for those languages. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/ru.ts | 19 +++++++++++++++++++ app/src/lib/i18n/zh-CN.ts | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index f46e3d2fc69..65371687f08 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4405,6 +4405,25 @@ const messages: TranslationMap = { 'composer.workspace.choose': 'Выбрать папку…', 'composer.workspace.hint': 'Выберите, где будет работать этот разговор', 'composer.workspace.error': 'Не удалось использовать эту папку', + 'composer.placeholder.running': 'Добавьте сообщение в очередь…', + 'composer.placeholder.waiting': 'Ответьте выше или напишите, чтобы добавить в очередь…', + 'composer.queueSend': 'В очередь · отправится по завершении', + 'composer.stopEsc': 'Остановить (Esc)', + 'chat.message.copy': 'Копировать', + 'chat.message.copyMessage': 'Копировать сообщение', + 'chat.message.refresh': 'Сгенерировать заново', + 'chat.message.goodResponse': 'Хороший ответ', + 'chat.message.badResponse': 'Плохой ответ', + 'chat.message.more': 'Ещё', + 'chat.message.readAloud': 'Прочитать вслух', + 'chat.message.stopReading': 'Остановить чтение', + 'chat.message.edit': 'Изменить', + 'chat.message.scrollToBottom': 'Прокрутить вниз', + 'chat.message.justNow': 'только что', + 'chat.message.minutesAgo': '{count} мин назад', + 'chat.message.hoursAgo': '{count} ч назад', + 'chat.message.interrupted': 'Прервано', + 'chat.message.interruptedDetail': 'Этот ответ оборвался, не завершившись.', 'composer.settings.model': 'Модель', 'composer.settings.temperature': 'Температура', 'composer.settings.chooseModel': 'Выбрать другую модель…', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 6264bea4852..ccff5c51916 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2019,7 +2019,7 @@ const messages: TranslationMap = { 'chat.todos.ofTotal': '已完成 {done},共 {total}', 'chat.todos.receipt': '进度已更新 — {done}/{total}', 'chat.subagents.ranFor': '运行了 {duration}', - 'chat.subagents.ofTotal': '{complete} / {total}', + 'chat.subagents.ofTotal': '已完成 {complete},共 {total}', 'chat.subagents.runningCount': '{count} 个运行中', 'chat.subagents.failedCount': '{count} 个失败', 'chat.subagents.settled': '子任务已完成,正在处理结果。', From ef84b13e0abbc9930aecb5c7726591090341e377 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:25:19 +0300 Subject: [PATCH 107/126] feat(i18n): add Simplified Chinese translations Added a zh-CN locale file with translations for the app's user-facing strings so the interface can be displayed in Simplified Chinese. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/lib/i18n/zh-CN.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index ccff5c51916..76585ad687a 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4125,6 +4125,25 @@ const messages: TranslationMap = { 'composer.workspace.choose': '选择文件夹…', 'composer.workspace.hint': '选择此对话的工作位置', 'composer.workspace.error': '无法使用该文件夹', + 'composer.placeholder.running': '排队发送一条后续消息…', + 'composer.placeholder.waiting': '请在上方回答,或输入以排队…', + 'composer.queueSend': '排队 · 完成后发送', + 'composer.stopEsc': '停止 (Esc)', + 'chat.message.copy': '复制', + 'chat.message.copyMessage': '复制消息', + 'chat.message.refresh': '重新生成', + 'chat.message.goodResponse': '回答不错', + 'chat.message.badResponse': '回答不好', + 'chat.message.more': '更多', + 'chat.message.readAloud': '朗读', + 'chat.message.stopReading': '停止朗读', + 'chat.message.edit': '编辑', + 'chat.message.scrollToBottom': '滚动到底部', + 'chat.message.justNow': '刚刚', + 'chat.message.minutesAgo': '{count} 分钟前', + 'chat.message.hoursAgo': '{count} 小时前', + 'chat.message.interrupted': '已中断', + 'chat.message.interruptedDetail': '此回复在完成前被中断。', 'composer.settings.model': '模型', 'composer.settings.temperature': '温度', 'composer.settings.chooseModel': '选择其他模型…', From 154da0dd274a811a5bd500bf0f91e4533af58d49 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:25:31 +0300 Subject: [PATCH 108/126] feat(conversations): persist thread drafts across navigation Draft text is now saved per thread so switching conversations no longer discards what was typed. The draft is restored when returning to a thread and cleared once the message is sent. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/components/assistant-ui/thread.tsx | 2 +- .../components/AssistantUiChat.tsx | 2 +- .../conversations/hooks/useThreadDraft.ts | 23 ++++++++----------- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/app/src/components/assistant-ui/thread.tsx b/app/src/components/assistant-ui/thread.tsx index f7a4661c130..29c60d57624 100644 --- a/app/src/components/assistant-ui/thread.tsx +++ b/app/src/components/assistant-ui/thread.tsx @@ -12,7 +12,6 @@ import { EditMessage } from '@/components/assistant-ui/elements/edit-message'; import { ErrorState } from '@/components/assistant-ui/elements/error-state'; import { Image } from '@/components/assistant-ui/elements/image'; import { MessageTiming } from '@/components/assistant-ui/elements/message-timing.aui'; -import { fullTimestamp, relativeTime } from '@/utils/relativeTime'; import { StoppedRun } from '@/components/assistant-ui/elements/stopped-run'; import { ToolFallback } from '@/components/assistant-ui/elements/tool-fallback'; import { File } from '@/components/assistant-ui/file'; @@ -34,6 +33,7 @@ import { import { useT } from '@/lib/i18n/I18nContext'; import { useAuiThreadId } from '@/providers/AssistantUiRuntimeProvider'; import { CHAT_ERROR_METADATA_KEY } from '@/store/threadSlice'; +import { fullTimestamp, relativeTime } from '@/utils/relativeTime'; import { useActionBarReload, useMessageError } from '@assistant-ui/core/react'; import { ActionBarMorePrimitive, diff --git a/app/src/features/conversations/components/AssistantUiChat.tsx b/app/src/features/conversations/components/AssistantUiChat.tsx index 76718a0b599..c7d3850ad6e 100644 --- a/app/src/features/conversations/components/AssistantUiChat.tsx +++ b/app/src/features/conversations/components/AssistantUiChat.tsx @@ -11,11 +11,11 @@ import type { Attachment } from '../../../lib/attachments'; import { useT } from '../../../lib/i18n/I18nContext'; import { AssistantUiRuntimeProvider } from '../../../providers/AssistantUiRuntimeProvider'; import { useAppSelector } from '../../../store/hooks'; -import { InterruptedTurnNotice } from '../aui/InterruptedTurnNotice'; import { AgentRunningStatus } from '../aui/AgentRunningStatus'; import { ChatConversationMap } from '../aui/ChatConversationMap'; import { ComposerTriggers } from '../aui/ComposerTriggers'; import { ContextUsage } from '../aui/ContextUsage'; +import { InterruptedTurnNotice } from '../aui/InterruptedTurnNotice'; import { ChatSources } from './aui/ChatSources'; import { ChatToolFallback } from './ChatToolParts'; diff --git a/app/src/features/conversations/hooks/useThreadDraft.ts b/app/src/features/conversations/hooks/useThreadDraft.ts index dc872ca01ed..909e198e2f3 100644 --- a/app/src/features/conversations/hooks/useThreadDraft.ts +++ b/app/src/features/conversations/hooks/useThreadDraft.ts @@ -82,19 +82,16 @@ export function useThreadDraft( useEffect(() => flush, [flush]); - const setDraft = useCallback( - (action: SetStateAction) => { - const current = threadIdRef.current; - setState(prev => { - const base = prev.threadId === current ? prev.text : ''; - const text = typeof action === 'function' ? action(base) : action; - return text === base && prev.threadId === current - ? prev - : { threadId: current, text, edited: true }; - }); - }, - [] - ); + const setDraft = useCallback((action: SetStateAction) => { + const current = threadIdRef.current; + setState(prev => { + const base = prev.threadId === current ? prev.text : ''; + const text = typeof action === 'function' ? action(base) : action; + return text === base && prev.threadId === current + ? prev + : { threadId: current, text, edited: true }; + }); + }, []); // Schedule the write for whatever the draft became. useEffect(() => { From 0e7fe5406d42bef6bdbe432c720d905e6cbfb26d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:26:02 +0300 Subject: [PATCH 109/126] feat(conversations): add conversation list view Adds a conversations feature component that renders the list of conversations for the current user. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/features/conversations/Conversations.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 03188eace25..6c31d1058a5 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -842,7 +842,8 @@ const Conversations = ({ window.addEventListener('dictation://insert-text', onDictationInsert as EventListener); return () => window.removeEventListener('dictation://insert-text', onDictationInsert as EventListener); - }, []); + // `setInputValue` is `useThreadDraft`'s stable setter. + }, [setInputValue]); useEffect(() => { if (sendErrorRef.current && inputValue.length > 0) { From ad96ea90372875519b31dd3d4d70d424f6c7cef5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:26:09 +0300 Subject: [PATCH 110/126] feat(assistant-ui): add todo list element Add a todo-list element for the assistant UI that renders a checklist of items with their completion state. This gives assistant responses a dedicated way to present task progress. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/components/assistant-ui/elements/todo-list.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/components/assistant-ui/elements/todo-list.tsx b/app/src/components/assistant-ui/elements/todo-list.tsx index f4857a3df33..bfa653bdfb2 100644 --- a/app/src/components/assistant-ui/elements/todo-list.tsx +++ b/app/src/components/assistant-ui/elements/todo-list.tsx @@ -70,6 +70,8 @@ export function TodoItems({ items }: { items: readonly TodoItem[] }) { {items.map(item => (
  • {item.status === 'done' ? ( @@ -186,6 +188,8 @@ export function TodoProgressCard({
    Date: Wed, 7 Oct 2026 01:26:24 +0300 Subject: [PATCH 111/126] feat(conversations): add conversation list view Add a Conversations feature component that renders the list of conversations for the current user. This gives the app a dedicated place to browse and select conversations. Auto-committed-on: dragonfly Co-authored-by: Medulla --- app/src/features/conversations/Conversations.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/features/conversations/Conversations.tsx b/app/src/features/conversations/Conversations.tsx index 6c31d1058a5..c96aa55f76a 100644 --- a/app/src/features/conversations/Conversations.tsx +++ b/app/src/features/conversations/Conversations.tsx @@ -1831,7 +1831,7 @@ const Conversations = ({ selectedStalledPhase && selectedThreadId ? ( ) : null; From e097f2bc3bbbab26f109d97751a456df4db9c734 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 7 Oct 2026 01:26:29 +0300 Subject: [PATCH 112/126] feat(conversations): add stall warning for assistant responses Show a warning when an assistant response appears to have stalled so users know the conversation is still in progress. Added tests covering the tool call rendering alongside the new warning. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .../conversations/aui/StallWarning.tsx | 19 ++++++++++++------- .../components/AssistantUiToolCall.test.tsx | 1 - 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/app/src/features/conversations/aui/StallWarning.tsx b/app/src/features/conversations/aui/StallWarning.tsx index 7effcad498c..9e818fb7dbf 100644 --- a/app/src/features/conversations/aui/StallWarning.tsx +++ b/app/src/features/conversations/aui/StallWarning.tsx @@ -12,8 +12,8 @@ import { useLiveElapsed } from './useLiveElapsed'; export interface StallWarningProps { /** `inferenceStatusByThread.phase` of the quiet turn. */ phase: string; - /** Epoch ms of the turn's last inference signal. */ - quietSince: number; + /** Epoch ms of the turn's last inference signal; unknown hides the duration. */ + quietSince: number | undefined; onStop: () => void; } @@ -24,17 +24,22 @@ function formatQuiet(ms: number): string { export function StallWarning({ phase, quietSince, onStop }: StallWarningProps) { const { t } = useT(); - const quietMs = useLiveElapsed(quietSince, true) ?? 0; + const quietMs = useLiveElapsed(quietSince, true); return (

    - {t(phase === 'thinking' ? 'chat.stallWarning.thinking' : 'chat.stallWarning.working')}{' '} - - {t('chat.status.quietFor').replace('{duration}', formatQuiet(quietMs))} - + {t(phase === 'thinking' ? 'chat.stallWarning.thinking' : 'chat.stallWarning.working')} + {quietMs !== undefined && ( + <> + {' '} + + {t('chat.status.quietFor').replace('{duration}', formatQuiet(quietMs))} + + + )}