diff --git a/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx b/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx index 722087d812..4ef6dcc941 100644 --- a/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx +++ b/app/src/components/intelligence/IntelligenceSubconsciousTab.tsx @@ -1,101 +1,72 @@ -import type { Dispatch, FormEvent, SetStateAction } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useDispatch } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import { useT } from '../../lib/i18n/I18nContext'; import { setSelectedThread } from '../../store/threadSlice'; -import type { - SubconsciousEscalation, - SubconsciousLogEntry, - SubconsciousStatus, - SubconsciousTask, -} from '../../utils/tauriCommands/subconscious'; +import type { SubconsciousMode } from '../../utils/tauriCommands/heartbeat'; +import type { SubconsciousStatus } from '../../utils/tauriCommands/subconscious'; import SubconsciousReflectionCards from './SubconsciousReflectionCards'; -const SKILL_KEYWORDS = - /\bskill\b|\boauth\b|\bnotion\b|\bgmail\b|\bintegration\b|\bdisconnect|\breconnect|\bre-?auth/i; - -function isSkillRelated(title: string, description: string): boolean { - return SKILL_KEYWORDS.test(title) || SKILL_KEYWORDS.test(description); +interface ModeOption { + id: SubconsciousMode; + titleKey: string; + descKey: string; } -function formatInterval(minutes: number, t: (key: string) => string): string { - switch (minutes) { - case 5: - return t('subconscious.interval.fiveMinutes'); - case 10: - return t('subconscious.interval.tenMinutes'); - case 15: - return t('subconscious.interval.fifteenMinutes'); - case 30: - return t('subconscious.interval.thirtyMinutes'); - case 60: - return t('subconscious.interval.oneHour'); - case 360: - return t('subconscious.interval.sixHours'); - case 720: - return t('subconscious.interval.twelveHours'); - case 1440: - return t('subconscious.interval.oneDay'); - default: - return String(minutes); - } +const MODE_OPTIONS: ModeOption[] = [ + { id: 'off', titleKey: 'subconscious.mode.off.title', descKey: 'subconscious.mode.off.desc' }, + { + id: 'simple', + titleKey: 'subconscious.mode.simple.title', + descKey: 'subconscious.mode.simple.desc', + }, + { + id: 'aggressive', + titleKey: 'subconscious.mode.aggressive.title', + descKey: 'subconscious.mode.aggressive.desc', + }, +]; + +const INTERVAL_STOPS = [5, 10, 15, 30, 60, 120, 360, 720, 1440]; + +function formatMinutes(minutes: number, t: (key: string) => string): string { + if (minutes < 60) return t('subconscious.interval.minutes').replace('{n}', String(minutes)); + const hours = minutes / 60; + if (hours === 1) return t('subconscious.interval.oneHour'); + if (hours === 24) return t('subconscious.interval.oneDay'); + return t('subconscious.interval.hours').replace('{n}', String(hours)); } -function formatPriority(priority: string, t: (key: string) => string): string { - switch (priority) { - case 'critical': - return t('subconscious.priority.critical'); - case 'important': - return t('subconscious.priority.important'); - default: - return t('subconscious.priority.normal'); - } +function minutesToSlider(minutes: number): number { + const idx = INTERVAL_STOPS.indexOf(minutes); + return idx >= 0 ? idx : 0; } -function formatDuration(durationMs: number, t: (key: string) => string): string { - if (durationMs > 1000) { - return t('subconscious.durationSeconds').replace('{seconds}', (durationMs / 1000).toFixed(1)); - } - return t('subconscious.durationMilliseconds').replace('{milliseconds}', String(durationMs)); +function sliderToMinutes(value: number): number { + return INTERVAL_STOPS[value] ?? 30; } interface IntelligenceSubconsciousTabProps { - addSubconsciousTask: (title: string) => Promise; - approveEscalation: (escalationId: string) => Promise; - dismissEscalation: (escalationId: string) => Promise; - expandedLogIds: Set; - logEntries: SubconsciousLogEntry[]; - newTaskTitle: string; - removeSubconsciousTask: (taskId: string) => Promise; - setExpandedLogIds: Dispatch>>; - setNewTaskTitle: (value: string) => void; status: SubconsciousStatus | null; - tasks: SubconsciousTask[]; - toggleSubconsciousTask: (taskId: string, enabled: boolean) => Promise; + mode: SubconsciousMode; + intervalMinutes: number; triggerTick: () => Promise; triggering: boolean; - escalations: SubconsciousEscalation[]; - loading: boolean; + settingMode: boolean; + setMode: (mode: SubconsciousMode) => Promise; + setIntervalMinutes: (minutes: number) => Promise; } export default function IntelligenceSubconsciousTab({ - addSubconsciousTask, - approveEscalation, - dismissEscalation, - escalations, - expandedLogIds, - loading, - logEntries, - newTaskTitle, - removeSubconsciousTask, - setExpandedLogIds, - setNewTaskTitle, status, - tasks, - toggleSubconsciousTask, + mode, + intervalMinutes, triggerTick, triggering, + settingMode, + setMode, + setIntervalMinutes, }: IntelligenceSubconsciousTabProps) { const { t } = useT(); const navigate = useNavigate(); @@ -104,189 +75,148 @@ export default function IntelligenceSubconsciousTab({ const providerUnavailableReason = providerUnavailable ? (status?.provider_unavailable_reason ?? t('subconscious.providerUnavailableTitle')) : null; + const isEnabled = mode !== 'off'; - // Reflection "Act" callback — sets the freshly-spawned thread as the - // selected one and navigates the user to the chat surface so they - // land in the new conversation. Reflections never write into existing - // threads (#623), so every act starts its own conversation. - // - // We dispatch `setSelectedThread` (NOT `setActiveThread`): the - // Conversations page reads `selectedThreadId` from the thread slice on - // mount and resumes that thread if present in the fetched list, - // falling back to the most recent thread otherwise. `activeThreadId` - // is a separate, runtime-only field used for in-flight chat-turn - // routing — setting it without `selectedThreadId` would not affect - // which thread the user lands on. - // - // Route is `/chat`, NOT `/conversations`. The repo's CLAUDE.md hash- - // route list is stale — `BottomTabBar` and `OpenhumanLinkModal` both - // navigate to `/chat`. Using `/conversations` falls through to a home - // redirect so the user ends up on `/home` instead of the new thread. - const handleNavigateToReflectionThread = (threadId: string) => { - console.debug('[subconscious-ui] reflection navigate:thread', { threadId }); - dispatch(setSelectedThread(threadId)); - navigate('/chat'); - }; + const [localSlider, setLocalSlider] = useState(() => minutesToSlider(intervalMinutes)); - const handleAddTask = async (e: FormEvent) => { - e.preventDefault(); - const title = newTaskTitle.trim(); - if (!title) return; - console.debug('[subconscious-ui] add task:start', { title }); - try { - await addSubconsciousTask(title); - setNewTaskTitle(''); - console.debug('[subconscious-ui] add task:success', { title }); - } catch (error) { - console.debug('[subconscious-ui] add task:error', { - title, - error: error instanceof Error ? error.message : String(error), - }); - } - }; + // Keep the local slider in sync when the prop changes from outside (e.g. after a refresh). + useEffect(() => { + setLocalSlider(minutesToSlider(intervalMinutes)); + }, [intervalMinutes]); - const handleRunTick = async () => { - console.debug('[subconscious-ui] run tick:start', { triggering }); - try { - await triggerTick(); - console.debug('[subconscious-ui] run tick:done'); - } catch (error) { - console.debug('[subconscious-ui] run tick:error', { - error: error instanceof Error ? error.message : String(error), - }); - } - }; + const handleSliderChange = useCallback((e: React.ChangeEvent) => { + const val = Number(e.target.value); + setLocalSlider(val); + }, []); - const handleApproveEscalation = async (escalationId: string) => { - console.debug('[subconscious-ui] escalation approve:start', { escalationId }); - try { - await approveEscalation(escalationId); - console.debug('[subconscious-ui] escalation approve:success', { escalationId }); - } catch (error) { - console.debug('[subconscious-ui] escalation approve:error', { - escalationId, - error: error instanceof Error ? error.message : String(error), - }); + const handleSliderCommit = useCallback(() => { + const minutes = sliderToMinutes(localSlider); + if (minutes !== intervalMinutes) { + void setIntervalMinutes(minutes); } - }; + }, [localSlider, intervalMinutes, setIntervalMinutes]); - const handleDismissEscalation = async (escalationId: string) => { - console.debug('[subconscious-ui] escalation dismiss:start', { escalationId }); - try { - await dismissEscalation(escalationId); - console.debug('[subconscious-ui] escalation dismiss:success', { escalationId }); - } catch (error) { - console.debug('[subconscious-ui] escalation dismiss:error', { - escalationId, - error: error instanceof Error ? error.message : String(error), - }); - } - }; - - const handleFixInSkills = (escalationId: string) => { - console.debug('[subconscious-ui] escalation fix in skills:navigate', { escalationId }); - navigate('/skills', { state: { subconsciousEscalationId: escalationId } }); - }; - - const handleToggleTask = async (taskId: string, enabled: boolean, title: string) => { - console.debug('[subconscious-ui] task toggle:start', { taskId, enabled, title }); - try { - await toggleSubconsciousTask(taskId, enabled); - console.debug('[subconscious-ui] task toggle:success', { taskId, enabled, title }); - } catch (error) { - console.debug('[subconscious-ui] task toggle:error', { - taskId, - enabled, - title, - error: error instanceof Error ? error.message : String(error), - }); - } + const handleNavigateToThread = (threadId: string) => { + dispatch(setSelectedThread(threadId)); + navigate('/chat'); }; - const handleRemoveTask = async (taskId: string, title: string) => { - console.debug('[subconscious-ui] task remove:start', { taskId, title }); + const handleRunTick = async () => { try { - await removeSubconsciousTask(taskId); - console.debug('[subconscious-ui] task remove:success', { taskId, title }); + await triggerTick(); } catch (error) { - console.debug('[subconscious-ui] task remove:error', { - taskId, - title, + console.debug('[subconscious-ui] run tick:error', { error: error instanceof Error ? error.message : String(error), }); } }; return ( -
-
-
- {status && ( - <> - - {status.task_count} {t('subconscious.tasks')} - - | - - {status.total_ticks} {t('subconscious.ticks')} +
+ {/* Mode selector */} +
+

+ {t('subconscious.mode.label')} +

+
+ {MODE_OPTIONS.map(opt => ( + + ))}
-
-
- - - - + {mode === 'aggressive' && ( +

+ {t('subconscious.mode.aggressiveWarning')} +

+ )} +
+ + {/* Frequency slider */} + {isEnabled && ( +
+
+ + + {formatMinutes(sliderToMinutes(localSlider), t)} + +
+ +
+ 5m + 1h + 24h +
+
+ )} + + {/* Status bar + Run Now */} + {isEnabled && ( +
+
+ {status && ( + <> + + {status.total_ticks} {t('subconscious.ticks')} + + {status.last_tick_at && ( + <> + | + + {t('subconscious.last')}:{' '} + {new Date(status.last_tick_at * 1000).toLocaleTimeString()} + + + )} + {status.consecutive_failures > 0 && ( + <> + | + + {status.consecutive_failures} {t('subconscious.failed')} + + + )} + + )}
-
+ )} - {providerUnavailable && ( + {isEnabled && providerUnavailable && (
@@ -325,256 +255,12 @@ export default function IntelligenceSubconsciousTab({
)} - - - {escalations.length > 0 && ( -
-

- - {t('subconscious.approvalNeeded')} - - {escalations.length} - -

-
- {escalations.map(esc => ( -
-
-
-

- {esc.title} -

-

- {esc.description} -

-
- - {formatPriority(esc.priority, t)} - - - {t('subconscious.requiresApproval')} - -
-
-
- {isSkillRelated(esc.title, esc.description) ? ( - - ) : ( - - )} - -
-
-
- ))} -
-
+ {isEnabled && ( + )} - -
-

- {t('subconscious.activeTasks')} -

- {loading && tasks.length === 0 ? ( -
-
-
- ) : tasks.filter(t => !t.completed).length === 0 ? ( -

- {t('subconscious.noActiveTasks')} -

- ) : ( -
- {tasks - .filter(t => !t.completed && t.source === 'system') - .map(task => ( -
-
- - {task.title} - - - {t('subconscious.default')} - -
- ))} - {tasks - .filter(t => !t.completed && t.source !== 'system') - .map(task => ( -
-
- - - {task.title} - -
- -
- ))} -
- )} - -
void handleAddTask(e)} className="flex gap-2 mt-3"> - setNewTaskTitle(e.target.value)} - className="flex-1 px-3 py-2 text-sm bg-white dark:bg-neutral-900 border border-stone-200 dark:border-neutral-800 rounded-lg text-stone-900 dark:text-neutral-100 placeholder-stone-400 focus:outline-none focus:border-primary-500/50 transition-colors" - /> - -
-
- -
-

- {t('subconscious.activityLog')} -

- {logEntries.length === 0 ? ( -

- {t('subconscious.noActivity')} -

- ) : ( -
- {logEntries.map(entry => ( -
- - {new Date(entry.tick_at * 1000).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - })} - - - 120 ? 'cursor-pointer hover:text-stone-900 dark:hover:text-neutral-100 dark:text-neutral-100' : ''}`} - onClick={() => { - if (entry.result && entry.result.length > 120) { - setExpandedLogIds(prev => { - const next = new Set(prev); - if (next.has(entry.id)) next.delete(entry.id); - else next.add(entry.id); - return next; - }); - } - }}> - {entry.result - ? expandedLogIds.has(entry.id) - ? entry.result - : entry.result.length > 120 - ? `${entry.result.substring(0, 120)}...` - : entry.result - : entry.decision === 'noop' - ? t('subconscious.decision.nothingNew') - : entry.decision === 'act' - ? t('subconscious.decision.completed') - : entry.decision === 'in_progress' - ? t('subconscious.decision.evaluating') - : entry.decision === 'escalate' - ? t('subconscious.decision.waitingApproval') - : entry.decision === 'failed' - ? t('subconscious.decision.failed') - : entry.decision === 'cancelled' - ? t('subconscious.decision.cancelled') - : entry.decision === 'dismissed' - ? t('subconscious.decision.skipped') - : entry.decision} - - {entry.duration_ms != null && ( - - {formatDuration(entry.duration_ms, t)} - - )} -
- ))} -
- )} -
); } diff --git a/app/src/components/intelligence/SubconsciousReflectionCards.tsx b/app/src/components/intelligence/SubconsciousReflectionCards.tsx index 67e4c7a1f2..ea02af58e7 100644 --- a/app/src/components/intelligence/SubconsciousReflectionCards.tsx +++ b/app/src/components/intelligence/SubconsciousReflectionCards.tsx @@ -259,6 +259,14 @@ export default function SubconsciousReflectionCards({ )}
+ {r.thread_id && ( + + )} {r.proposed_action && (