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:
Steven Enamakel
2026-06-01 22:02:46 -07:00
committed by GitHub
co-authored by Steven Enamakel's Droid oxoxDev sanil-23 Claude Opus 4.8
parent 3136ef5483
commit 97ff4f53d3
51 changed files with 1399 additions and 5167 deletions
@@ -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,
};
}