mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
refactor(subconscious): replace task system with agent-per-tick model (#3079)
Co-authored-by: Steven Enamakel's Droid <enamakel.agent@tinyhumans.ai> Co-authored-by: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Co-authored-by: sanil-23 <sanil@tinyhumans.ai> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Steven Enamakel's Droid
oxoxDev
sanil-23
Claude Opus 4.8
parent
3136ef5483
commit
97ff4f53d3
@@ -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<void>;
|
||||
approveEscalation: (escalationId: string) => Promise<void>;
|
||||
dismissEscalation: (escalationId: string) => Promise<void>;
|
||||
expandedLogIds: Set<string>;
|
||||
logEntries: SubconsciousLogEntry[];
|
||||
newTaskTitle: string;
|
||||
removeSubconsciousTask: (taskId: string) => Promise<void>;
|
||||
setExpandedLogIds: Dispatch<SetStateAction<Set<string>>>;
|
||||
setNewTaskTitle: (value: string) => void;
|
||||
status: SubconsciousStatus | null;
|
||||
tasks: SubconsciousTask[];
|
||||
toggleSubconsciousTask: (taskId: string, enabled: boolean) => Promise<void>;
|
||||
mode: SubconsciousMode;
|
||||
intervalMinutes: number;
|
||||
triggerTick: () => Promise<void>;
|
||||
triggering: boolean;
|
||||
escalations: SubconsciousEscalation[];
|
||||
loading: boolean;
|
||||
settingMode: boolean;
|
||||
setMode: (mode: SubconsciousMode) => Promise<void>;
|
||||
setIntervalMinutes: (minutes: number) => Promise<void>;
|
||||
}
|
||||
|
||||
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,52 +75,35 @@ 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 });
|
||||
const [localSlider, setLocalSlider] = useState(() => minutesToSlider(intervalMinutes));
|
||||
|
||||
// Keep the local slider in sync when the prop changes from outside (e.g. after a refresh).
|
||||
useEffect(() => {
|
||||
setLocalSlider(minutesToSlider(intervalMinutes));
|
||||
}, [intervalMinutes]);
|
||||
|
||||
const handleSliderChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = Number(e.target.value);
|
||||
setLocalSlider(val);
|
||||
}, []);
|
||||
|
||||
const handleSliderCommit = useCallback(() => {
|
||||
const minutes = sliderToMinutes(localSlider);
|
||||
if (minutes !== intervalMinutes) {
|
||||
void setIntervalMinutes(minutes);
|
||||
}
|
||||
}, [localSlider, intervalMinutes, setIntervalMinutes]);
|
||||
|
||||
const handleNavigateToThread = (threadId: string) => {
|
||||
dispatch(setSelectedThread(threadId));
|
||||
navigate('/chat');
|
||||
};
|
||||
|
||||
const handleAddTask = async (e: FormEvent<HTMLFormElement>) => {
|
||||
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),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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),
|
||||
@@ -157,136 +111,112 @@ export default function IntelligenceSubconsciousTab({
|
||||
}
|
||||
};
|
||||
|
||||
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 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 handleRemoveTask = async (taskId: string, title: string) => {
|
||||
console.debug('[subconscious-ui] task remove:start', { taskId, title });
|
||||
try {
|
||||
await removeSubconsciousTask(taskId);
|
||||
console.debug('[subconscious-ui] task remove:success', { taskId, title });
|
||||
} catch (error) {
|
||||
console.debug('[subconscious-ui] task remove:error', {
|
||||
taskId,
|
||||
title,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fade-up">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs text-stone-400 dark:text-neutral-500">
|
||||
{status && (
|
||||
<>
|
||||
<span>
|
||||
{status.task_count} {t('subconscious.tasks')}
|
||||
</span>
|
||||
<span className="text-stone-300 dark:text-neutral-600">|</span>
|
||||
<span>
|
||||
{status.total_ticks} {t('subconscious.ticks')}
|
||||
</span>
|
||||
{status.last_tick_at && (
|
||||
<>
|
||||
<span className="text-stone-300 dark:text-neutral-600">|</span>
|
||||
<span>
|
||||
{t('subconscious.last')}:{' '}
|
||||
{new Date(status.last_tick_at * 1000).toLocaleTimeString()}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{status.consecutive_failures > 0 && (
|
||||
<>
|
||||
<span className="text-stone-300 dark:text-neutral-600">|</span>
|
||||
<span className="text-coral-500">
|
||||
{status.consecutive_failures} {t('subconscious.failed')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<svg
|
||||
className="w-3 h-3 text-stone-400 dark:text-neutral-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
<div className="space-y-5 animate-fade-up">
|
||||
{/* Mode selector */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-2">
|
||||
{t('subconscious.mode.label')}
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{MODE_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
disabled={settingMode}
|
||||
onClick={() => void setMode(opt.id)}
|
||||
className={`flex flex-col items-center text-center rounded-lg border p-3 transition ${
|
||||
mode === opt.id
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-500/10'
|
||||
: 'border-stone-200 dark:border-neutral-800 hover:border-primary-300 dark:hover:border-primary-500/40'
|
||||
} ${settingMode ? 'opacity-60 cursor-wait' : ''}`}>
|
||||
<span
|
||||
className={`inline-block w-3 h-3 rounded-full border-2 mb-1.5 ${
|
||||
mode === opt.id
|
||||
? 'bg-primary-500 border-primary-500'
|
||||
: 'border-stone-300 dark:border-neutral-600'
|
||||
}`}
|
||||
/>
|
||||
</svg>
|
||||
<select
|
||||
value={status?.interval_minutes ?? 5}
|
||||
onChange={() => {
|
||||
// Config update would require restart — show as read-only for now
|
||||
}}
|
||||
disabled
|
||||
title={t('subconscious.tickInterval')}
|
||||
className="text-xs bg-stone-50 dark:bg-neutral-800/60 border border-stone-200 dark:border-neutral-800 rounded px-1.5 py-0.5 text-stone-500 dark:text-neutral-400 cursor-not-allowed">
|
||||
<option value={5}>{formatInterval(5, t)}</option>
|
||||
<option value={10}>{formatInterval(10, t)}</option>
|
||||
<option value={15}>{formatInterval(15, t)}</option>
|
||||
<option value={30}>{formatInterval(30, t)}</option>
|
||||
<option value={60}>{formatInterval(60, t)}</option>
|
||||
<option value={360}>{formatInterval(360, t)}</option>
|
||||
<option value={720}>{formatInterval(720, t)}</option>
|
||||
<option value={1440}>{formatInterval(1440, t)}</option>
|
||||
</select>
|
||||
<span className="text-sm font-medium text-stone-900 dark:text-neutral-100">
|
||||
{t(opt.titleKey)}
|
||||
</span>
|
||||
<p className="mt-1 text-[11px] leading-tight text-stone-500 dark:text-neutral-400">
|
||||
{t(opt.descKey)}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{mode === 'aggressive' && (
|
||||
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
{t('subconscious.mode.aggressiveWarning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Frequency slider */}
|
||||
{isEnabled && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-xs font-medium text-stone-700 dark:text-neutral-300">
|
||||
{t('subconscious.interval.label')}
|
||||
</label>
|
||||
<span className="text-xs text-stone-500 dark:text-neutral-400">
|
||||
{formatMinutes(sliderToMinutes(localSlider), t)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={INTERVAL_STOPS.length - 1}
|
||||
step={1}
|
||||
value={localSlider}
|
||||
onChange={handleSliderChange}
|
||||
onMouseUp={handleSliderCommit}
|
||||
onTouchEnd={handleSliderCommit}
|
||||
className="w-full h-1.5 rounded-full appearance-none cursor-pointer bg-stone-200 dark:bg-neutral-700 accent-primary-500"
|
||||
/>
|
||||
<div className="flex justify-between mt-1 text-[10px] text-stone-400 dark:text-neutral-500">
|
||||
<span>5m</span>
|
||||
<span>1h</span>
|
||||
<span>24h</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status bar + Run Now */}
|
||||
{isEnabled && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs text-stone-400 dark:text-neutral-500">
|
||||
{status && (
|
||||
<>
|
||||
<span>
|
||||
{status.total_ticks} {t('subconscious.ticks')}
|
||||
</span>
|
||||
{status.last_tick_at && (
|
||||
<>
|
||||
<span className="text-stone-300 dark:text-neutral-600">|</span>
|
||||
<span>
|
||||
{t('subconscious.last')}:{' '}
|
||||
{new Date(status.last_tick_at * 1000).toLocaleTimeString()}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{status.consecutive_failures > 0 && (
|
||||
<>
|
||||
<span className="text-stone-300 dark:text-neutral-600">|</span>
|
||||
<span className="text-coral-500">
|
||||
{status.consecutive_failures} {t('subconscious.failed')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void handleRunTick()}
|
||||
disabled={triggering || providerUnavailable}
|
||||
title={providerUnavailable ? t('subconscious.providerUnavailableTitle') : undefined}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 disabled:opacity-40 border border-stone-200 dark:border-neutral-800 rounded-lg text-stone-600 dark:text-neutral-300 transition-colors">
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 disabled:opacity-40 border border-stone-200 dark:border-neutral-800 rounded-lg text-stone-600 dark:text-neutral-300 transition-colors">
|
||||
{triggering ? (
|
||||
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
@@ -302,9 +232,9 @@ export default function IntelligenceSubconsciousTab({
|
||||
{t('subconscious.runNow')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{providerUnavailable && (
|
||||
{isEnabled && providerUnavailable && (
|
||||
<div className="rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
@@ -325,256 +255,12 @@ export default function IntelligenceSubconsciousTab({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SubconsciousReflectionCards
|
||||
onNavigateToThread={handleNavigateToReflectionThread}
|
||||
pollIntervalMs={15_000}
|
||||
/>
|
||||
|
||||
{escalations.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-3 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse" />
|
||||
{t('subconscious.approvalNeeded')}
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300">
|
||||
{escalations.length}
|
||||
</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{escalations.map(esc => (
|
||||
<div
|
||||
key={esc.id}
|
||||
className="bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-xl p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-stone-900 dark:text-neutral-100">
|
||||
{esc.title}
|
||||
</p>
|
||||
<p className="text-xs text-stone-500 dark:text-neutral-400 mt-1">
|
||||
{esc.description}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 rounded-full ${
|
||||
esc.priority === 'critical'
|
||||
? 'bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300'
|
||||
: esc.priority === 'important'
|
||||
? 'bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-300'
|
||||
: 'bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300'
|
||||
}`}>
|
||||
{formatPriority(esc.priority, t)}
|
||||
</span>
|
||||
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
|
||||
{t('subconscious.requiresApproval')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-3 flex-shrink-0">
|
||||
{isSkillRelated(esc.title, esc.description) ? (
|
||||
<button
|
||||
onClick={() => handleFixInSkills(esc.id)}
|
||||
className="px-3 py-1.5 text-xs bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors">
|
||||
{t('subconscious.fixInConnections')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => void handleApproveEscalation(esc.id)}
|
||||
className="px-3 py-1.5 text-xs bg-sage-500 hover:bg-sage-600 text-white rounded-lg transition-colors">
|
||||
{t('subconscious.goAhead')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => void handleDismissEscalation(esc.id)}
|
||||
className="px-3 py-1.5 text-xs bg-stone-100 dark:bg-neutral-800 hover:bg-stone-200 text-stone-600 dark:text-neutral-300 rounded-lg transition-colors">
|
||||
{t('common.skip')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{isEnabled && (
|
||||
<SubconsciousReflectionCards
|
||||
onNavigateToThread={handleNavigateToThread}
|
||||
pollIntervalMs={15_000}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-3">
|
||||
{t('subconscious.activeTasks')}
|
||||
</h3>
|
||||
{loading && tasks.length === 0 ? (
|
||||
<div className="text-center py-4">
|
||||
<div className="w-6 h-6 mx-auto border-2 border-stone-300 dark:border-neutral-700 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : tasks.filter(t => !t.completed).length === 0 ? (
|
||||
<p className="text-xs text-stone-400 dark:text-neutral-500 py-3">
|
||||
{t('subconscious.noActiveTasks')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{tasks
|
||||
.filter(t => !t.completed && t.source === 'system')
|
||||
.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center py-2 px-3 bg-stone-50 dark:bg-neutral-800/60 rounded-lg">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-sage-400 flex-shrink-0 mr-2.5" />
|
||||
<span className="text-sm text-stone-900 dark:text-neutral-100 truncate flex-1">
|
||||
{task.title}
|
||||
</span>
|
||||
<span className="text-[10px] text-stone-400 dark:text-neutral-500 flex-shrink-0 px-1.5 py-0.5 rounded bg-stone-100 dark:bg-neutral-800">
|
||||
{t('subconscious.default')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{tasks
|
||||
.filter(t => !t.completed && t.source !== 'system')
|
||||
.map(task => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center justify-between py-2 px-3 bg-stone-50 dark:bg-neutral-800/60 rounded-lg group">
|
||||
<div className="flex items-center gap-2.5 flex-1 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={task.enabled}
|
||||
aria-label={`${task.enabled ? t('common.disable') : t('common.enable')} ${task.title}`}
|
||||
onClick={() => void handleToggleTask(task.id, !task.enabled, task.title)}
|
||||
className={`relative w-7 h-4 rounded-full flex-shrink-0 transition-colors ${
|
||||
task.enabled ? 'bg-sage-500' : 'bg-stone-300'
|
||||
}`}>
|
||||
<span
|
||||
className={`absolute top-0.5 left-0.5 w-3 h-3 rounded-full bg-white dark:bg-neutral-900 shadow transition-transform ${
|
||||
task.enabled ? 'translate-x-3' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span
|
||||
className={`text-sm truncate ${task.enabled ? 'text-stone-900 dark:text-neutral-100' : 'text-stone-400 dark:text-neutral-500'}`}>
|
||||
{task.title}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('common.remove')} ${task.title}`}
|
||||
onClick={() => void handleRemoveTask(task.id, task.title)}
|
||||
className="opacity-0 group-hover:opacity-100 p-1 text-stone-400 dark:text-neutral-500 hover:text-coral-500 transition-all">
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={e => void handleAddTask(e)} className="flex gap-2 mt-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('subconscious.addTaskPlaceholder')}
|
||||
value={newTaskTitle}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newTaskTitle.trim()}
|
||||
className="px-3 py-2 text-sm bg-primary-500 hover:bg-primary-600 disabled:opacity-40 text-white rounded-lg transition-colors">
|
||||
{t('common.add')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-3">
|
||||
{t('subconscious.activityLog')}
|
||||
</h3>
|
||||
{logEntries.length === 0 ? (
|
||||
<p className="text-xs text-stone-400 dark:text-neutral-500 py-3">
|
||||
{t('subconscious.noActivity')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{logEntries.map(entry => (
|
||||
<div key={entry.id} className="flex items-start gap-2 py-1.5 px-2 text-xs">
|
||||
<span className="text-stone-400 dark:text-neutral-500 flex-shrink-0 w-14">
|
||||
{new Date(entry.tick_at * 1000).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={`flex-shrink-0 w-1.5 h-1.5 rounded-full mt-1.5 ${
|
||||
entry.decision === 'act'
|
||||
? 'bg-sage-400'
|
||||
: entry.decision === 'in_progress'
|
||||
? 'bg-primary-400 animate-pulse'
|
||||
: entry.decision === 'escalate'
|
||||
? 'bg-amber-400'
|
||||
: entry.decision === 'failed'
|
||||
? 'bg-coral-400'
|
||||
: entry.decision === 'cancelled'
|
||||
? 'bg-stone-300'
|
||||
: entry.decision === 'dismissed'
|
||||
? 'bg-stone-300'
|
||||
: 'bg-stone-200 dark:bg-neutral-800'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`break-words min-w-0 ${
|
||||
entry.decision === 'in_progress'
|
||||
? 'text-stone-400 dark:text-neutral-500'
|
||||
: entry.decision === 'failed'
|
||||
? 'text-coral-500'
|
||||
: 'text-stone-600 dark:text-neutral-300'
|
||||
} ${entry.result && entry.result.length > 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}
|
||||
</span>
|
||||
{entry.duration_ms != null && (
|
||||
<span className="text-stone-300 dark:text-neutral-600 flex-shrink-0 ml-auto">
|
||||
{formatDuration(entry.duration_ms, t)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -259,6 +259,14 @@ export default function SubconsciousReflectionCards({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 flex-shrink-0">
|
||||
{r.thread_id && (
|
||||
<button
|
||||
data-testid={`reflection-view-${r.id}`}
|
||||
onClick={() => onNavigateToThread?.(r.thread_id!)}
|
||||
className="px-3 py-1.5 text-xs bg-stone-50 dark:bg-neutral-800/60 hover:bg-stone-100 dark:hover:bg-neutral-800 border border-stone-200 dark:border-neutral-700 text-stone-600 dark:text-neutral-300 rounded-lg transition-colors">
|
||||
{t('reflections.viewConversation')}
|
||||
</button>
|
||||
)}
|
||||
{r.proposed_action && (
|
||||
<button
|
||||
data-testid={`reflection-act-${r.id}`}
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
/**
|
||||
* Vitest for the Intelligence Subconscious tab (#623).
|
||||
*
|
||||
* Covers `handleNavigateToReflectionThread` — the callback passed to
|
||||
* `SubconsciousReflectionCards`. The function is small but load-bearing:
|
||||
* it dispatches `setSelectedThread(threadId)` so `Conversations` resumes
|
||||
* the new thread on mount, then routes to `/chat` (the unified chat
|
||||
* surface; `/conversations` redirects to `/home`). Both dispatch and
|
||||
* navigate are mocked so we can assert the contract without spinning up
|
||||
* the full Redux/router stack.
|
||||
* Vitest for the Intelligence Subconscious tab.
|
||||
*/
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { ComponentProps } from 'react';
|
||||
@@ -23,10 +15,6 @@ vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch, useSelector: ()
|
||||
|
||||
vi.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate }));
|
||||
|
||||
// Stub out the cards component so we can trigger the navigate callback
|
||||
// directly without exercising the RPC / polling path (already covered by
|
||||
// `SubconsciousReflectionCards.test.tsx`). The stub renders a button
|
||||
// that fires `onNavigateToThread` with a known thread id when clicked.
|
||||
vi.mock('../SubconsciousReflectionCards', () => ({
|
||||
default: ({ onNavigateToThread }: { onNavigateToThread?: (id: string) => void }) => (
|
||||
<button
|
||||
@@ -38,24 +26,16 @@ vi.mock('../SubconsciousReflectionCards', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
function baseProps() {
|
||||
function baseProps(): ComponentProps<typeof IntelligenceSubconsciousTab> {
|
||||
return {
|
||||
addSubconsciousTask: vi.fn(),
|
||||
approveEscalation: vi.fn(),
|
||||
dismissEscalation: vi.fn(),
|
||||
expandedLogIds: new Set<string>(),
|
||||
logEntries: [],
|
||||
newTaskTitle: '',
|
||||
removeSubconsciousTask: vi.fn(),
|
||||
setExpandedLogIds: vi.fn(),
|
||||
setNewTaskTitle: vi.fn(),
|
||||
status: null as ComponentProps<typeof IntelligenceSubconsciousTab>['status'],
|
||||
tasks: [],
|
||||
toggleSubconsciousTask: vi.fn(),
|
||||
status: null,
|
||||
mode: 'off',
|
||||
intervalMinutes: 30,
|
||||
triggerTick: vi.fn(),
|
||||
triggering: false,
|
||||
escalations: [],
|
||||
loading: false,
|
||||
settingMode: false,
|
||||
setMode: vi.fn(),
|
||||
setIntervalMinutes: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,48 +48,41 @@ describe('IntelligenceSubconsciousTab', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('on Act → dispatches setSelectedThread + navigates to /chat', () => {
|
||||
it('renders three mode options', () => {
|
||||
render(<IntelligenceSubconsciousTab {...baseProps()} />);
|
||||
expect(screen.getByText('Off')).toBeInTheDocument();
|
||||
expect(screen.getByText('Simple')).toBeInTheDocument();
|
||||
expect(screen.getByText('Aggressive')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking a mode option calls setMode', () => {
|
||||
const setMode = vi.fn();
|
||||
render(<IntelligenceSubconsciousTab {...baseProps()} setMode={setMode} />);
|
||||
fireEvent.click(screen.getByText('Simple'));
|
||||
expect(setMode).toHaveBeenCalledWith('simple');
|
||||
});
|
||||
|
||||
it('hides Run Now and reflections when mode is off', () => {
|
||||
render(<IntelligenceSubconsciousTab {...baseProps()} mode="off" />);
|
||||
expect(screen.queryByText('Run Now')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('cards-stub-trigger')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Run Now and reflections when mode is simple', () => {
|
||||
render(<IntelligenceSubconsciousTab {...baseProps()} mode="simple" />);
|
||||
expect(screen.getByText('Run Now')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('cards-stub-trigger')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows aggressive warning when mode is aggressive', () => {
|
||||
render(<IntelligenceSubconsciousTab {...baseProps()} mode="aggressive" />);
|
||||
expect(screen.getByText(/full tool access including writes/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('on Act → dispatches setSelectedThread + navigates to /chat', () => {
|
||||
render(<IntelligenceSubconsciousTab {...baseProps()} mode="simple" />);
|
||||
fireEvent.click(screen.getByTestId('cards-stub-trigger'));
|
||||
// Redux dispatch payload should match the slice's action creator
|
||||
// exactly — comparing the produced action keeps the assertion robust
|
||||
// if the slice path changes.
|
||||
expect(mockDispatch).toHaveBeenCalledWith(setSelectedThread('spawned-thread-42'));
|
||||
// Route must be `/chat` (the unified chat surface), not
|
||||
// `/conversations` — the latter falls through to a `/home` redirect
|
||||
// and the user lands somewhere unexpected.
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/chat');
|
||||
});
|
||||
|
||||
it('shows provider unavailable state and blocks manual ticks', () => {
|
||||
const triggerTick = vi.fn();
|
||||
render(
|
||||
<IntelligenceSubconsciousTab
|
||||
{...baseProps()}
|
||||
triggerTick={triggerTick}
|
||||
status={{
|
||||
enabled: true,
|
||||
provider_available: false,
|
||||
provider_unavailable_reason: 'Sign in or configure a local Subconscious provider.',
|
||||
interval_minutes: 5,
|
||||
last_tick_at: null,
|
||||
total_ticks: 0,
|
||||
task_count: 3,
|
||||
pending_escalations: 0,
|
||||
consecutive_failures: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Subconscious is paused')).toBeInTheDocument();
|
||||
expect(screen.getByText(/configure a local Subconscious provider/i)).toBeInTheDocument();
|
||||
|
||||
const runNow = screen.getByRole('button', { name: /Run Now/i });
|
||||
expect(runNow).toBeDisabled();
|
||||
fireEvent.click(runNow);
|
||||
expect(triggerTick).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /AI settings/i }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/settings/llm');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ function refl(overrides: Partial<Reflection> = {}): Reflection {
|
||||
created_at: 1,
|
||||
acted_on_at: null,
|
||||
dismissed_at: null,
|
||||
thread_id: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user