feat: Agent Tasks board + Settings > Agents registry panel (#2998)

This commit is contained in:
Steven Enamakel
2026-05-29 18:11:19 -07:00
committed by GitHub
parent 86b7b6f30f
commit b008a0e93e
75 changed files with 2760 additions and 529 deletions
@@ -1,45 +0,0 @@
import { render, screen } from '@testing-library/react';
// import { fireEvent, waitFor } from '@testing-library/react'; // re-enable with the full UI
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { joinMeetCall } from '../../services/meetCallService';
import IntelligenceCallsTab from './IntelligenceCallsTab';
vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn(async () => () => undefined) }));
vi.mock('../../services/meetCallService', () => ({
joinMeetCall: vi.fn(),
closeMeetCall: vi.fn(),
listMeetCalls: vi.fn().mockResolvedValue([]),
}));
describe('IntelligenceCallsTab', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('renders coming soon placeholder', () => {
render(<IntelligenceCallsTab />);
expect(screen.getByText('Calls')).toBeInTheDocument();
expect(screen.getByText('Coming Soon')).toBeInTheDocument();
});
it('does not render a form in the coming soon view', () => {
render(<IntelligenceCallsTab />);
expect(screen.queryByRole('form')).not.toBeInTheDocument();
});
it('accepts an onToast prop without throwing', () => {
const onToast = vi.fn();
expect(() => render(<IntelligenceCallsTab onToast={onToast} />)).not.toThrow();
});
it('does not call joinMeetCall on initial render', () => {
render(<IntelligenceCallsTab />);
expect(joinMeetCall).not.toHaveBeenCalled();
});
});
@@ -1,241 +0,0 @@
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { useEffect, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { closeMeetCall, joinMeetCall } from '../../services/meetCallService';
type ActiveCall = { requestId: string; meetUrl: string; displayName: string };
type Props = {
onToast?: (toast: {
type: 'success' | 'error' | 'info';
title: string;
message?: string;
}) => void;
};
const PLACEHOLDER_URL = 'https://meet.google.com/abc-defg-hij';
/**
* Calls tab on the Intelligence page.
*
* Lets the user paste a Google Meet link, choose a display name, and have
* the agent join the call as an anonymous guest in a dedicated CEF
* webview window. The window itself is opened by the Tauri shell — this
* component just collects inputs, fires the RPC + invoke pair, and
* tracks active calls so the user can close them from the same surface.
*/
export default function IntelligenceCallsTab({ onToast }: Props) {
const { t } = useT();
const [meetUrl, setMeetUrl] = useState('');
const [displayName, setDisplayName] = useState('OpenHuman Agent');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeCalls, setActiveCalls] = useState<ActiveCall[]>([]);
// Listen for shell-emitted close events so the in-flight list stays
// accurate when the user closes a Meet window directly. Outside the
// Tauri shell `listen` rejects with a transport error — we swallow it.
useEffect(() => {
let unlisten: UnlistenFn | undefined;
let cancelled = false;
listen<{ request_id: string }>('meet-call:closed', event => {
const closedId = event.payload?.request_id;
if (!closedId) return;
setActiveCalls(prev => prev.filter(call => call.requestId !== closedId));
})
.then(stop => {
if (cancelled) stop();
else unlisten = stop;
})
.catch(() => {
// Browser dev surface — no Tauri event bridge available.
});
return () => {
cancelled = true;
if (unlisten) unlisten();
};
}, []);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setSubmitting(true);
try {
// ownerDisplayName left empty here because this tab's UI is hidden
// behind a "Coming Soon" gate (see render branch below) — the call
// is dead-code-reachable only. When the tab is revived it must
// collect an owner-name input the same way `MeetingBotsCard` does
// (privacy lock for the in-call wake gate). Empty fails closed in
// core, so we're safe in the meantime.
const result = await joinMeetCall({ meetUrl, displayName, ownerDisplayName: '' });
setActiveCalls(prev => [
...prev.filter(call => call.requestId !== result.requestId),
{ requestId: result.requestId, meetUrl: result.meetUrl, displayName: result.displayName },
]);
setMeetUrl('');
onToast?.({
type: 'success',
title: t('calls.joiningCall'),
message: t('calls.meetWindowOpening'),
});
} catch (err) {
const message = err instanceof Error ? err.message : t('calls.failedToStart');
setError(message);
onToast?.({ type: 'error', title: t('calls.couldNotStart'), message });
} finally {
setSubmitting(false);
}
};
const handleClose = async (requestId: string) => {
try {
const closed = await closeMeetCall(requestId);
if (closed) {
// Only drop the row when the shell confirms the window is gone.
// The `meet-call:closed` event listener also clears the row, so
// a manual window-close still keeps the list accurate.
setActiveCalls(prev => prev.filter(call => call.requestId !== requestId));
}
} catch (err) {
const message = err instanceof Error ? err.message : t('calls.failedToClose');
onToast?.({ type: 'error', title: t('calls.couldNotClose'), message });
}
};
// Suppress unused-variable warnings while the UI is hidden behind Coming Soon.
void t;
void meetUrl;
void setMeetUrl;
void displayName;
void setDisplayName;
void submitting;
void error;
void activeCalls;
void handleSubmit;
void handleClose;
void PLACEHOLDER_URL;
return (
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-50 dark:bg-primary-500/10">
<svg
className="h-7 w-7 text-primary-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z"
/>
</svg>
</div>
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{t('memory.tab.calls')}
</h2>
<p className="mt-2 text-sm text-stone-500 dark:text-neutral-400 max-w-xs">
{t('calls.comingSoonDescription')}
</p>
<span className="mt-4 inline-flex items-center rounded-full bg-primary-50 dark:bg-primary-500/10 px-3 py-1 text-xs font-medium text-primary-600 dark:text-primary-400">
{t('common.comingSoon')}
</span>
</div>
);
/* Original Calls UI — re-enable when the feature is ready
return (
<div className="space-y-6">
<div>
<h2 className="text-base font-semibold text-stone-900 dark:text-neutral-100">
{t('calls.joinMeet')}
</h2>
<p className="mt-1 text-sm text-stone-500 dark:text-neutral-400">
{t('calls.joinMeetDescription')}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<label className="block">
<span className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('calls.meetLink')}
</span>
<input
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
value={meetUrl}
onChange={e => setMeetUrl(e.target.value)}
placeholder={PLACEHOLDER_URL}
className="mt-1 w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:text-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100"
required
/>
</label>
<label className="block">
<span className="text-xs font-medium uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('calls.displayName')}
</span>
<input
type="text"
value={displayName}
onChange={e => setDisplayName(e.target.value)}
maxLength={64}
className="mt-1 w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100"
required
/>
</label>
{error && (
<div
role="alert"
className="rounded-xl border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-sm text-coral-700 dark:text-coral-300">
{error}
</div>
)}
<button
type="submit"
disabled={submitting || !meetUrl.trim() || !displayName.trim()}
className="inline-flex items-center justify-center rounded-xl border border-primary-600 bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-soft transition hover:bg-primary-500 disabled:cursor-not-allowed disabled:opacity-50">
{submitting ? t('calls.openingMeet') : t('calls.joinCall')}
</button>
</form>
{activeCalls.length > 0 && (
<div className="space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('calls.activeCalls')}
</h3>
<ul className="space-y-2">
{activeCalls.map(call => (
<li
key={call.requestId}
className="flex items-center justify-between gap-3 rounded-xl border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-3 py-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-stone-900 dark:text-neutral-100">
{call.displayName}
</div>
<div className="truncate text-xs text-stone-500 dark:text-neutral-400">
{call.meetUrl}
</div>
</div>
<button
type="button"
onClick={() => handleClose(call.requestId)}
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-1 text-xs text-stone-600 dark:text-neutral-300 hover:border-coral-300 hover:text-coral-600 dark:text-coral-300">
{t('calls.leave')}
</button>
</li>
))}
</ul>
</div>
)}
</div>
);
*/
}
@@ -1,19 +0,0 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import IntelligenceDreamsTab from './IntelligenceDreamsTab';
describe('<IntelligenceDreamsTab />', () => {
it('renders the dreams title, description and coming-soon line', () => {
render(<IntelligenceDreamsTab />);
// useT resolves against the bundled English map by default.
expect(screen.getByRole('heading', { level: 2, name: /^Dreams$/ })).toBeInTheDocument();
expect(screen.getByText(/AI-generated reflections/i)).toBeInTheDocument();
expect(screen.getByText(/Coming soon/i)).toBeInTheDocument();
});
it('renders a decorative svg icon', () => {
const { container } = render(<IntelligenceDreamsTab />);
expect(container.querySelector('svg')).not.toBeNull();
});
});
@@ -1,30 +0,0 @@
import { useT } from '../../lib/i18n/I18nContext';
export default function IntelligenceDreamsTab() {
const { t } = useT();
return (
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-sky-50 dark:bg-sky-500/10">
<svg className="w-8 h-8 text-sky-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<h2 className="text-lg font-semibold text-stone-900 dark:text-neutral-100 mb-2">
{t('memory.tab.dreams')}
</h2>
<p className="text-stone-400 dark:text-neutral-500 text-sm mb-1">{t('dreams.description')}</p>
<p className="text-xs text-stone-500 dark:text-neutral-400">{t('dreams.comingSoon')}</p>
</div>
);
}
@@ -1,26 +1,29 @@
/**
* IntelligenceTasksTab — shows all agent task boards across conversations.
* IntelligenceTasksTab — shows all task boards across the workspace.
*
* Aggregates:
* 1. Live boards from `chatRuntime.taskBoardByThread` (updated in real-time
* while a conversation is running via socket events).
* 2. Persisted boards fetched once on mount from `threadApi.listTurnStates`
* (each turn state may carry a saved `taskBoard`).
* Surfaces three sources, in priority order:
* 1. The user's personal board ({@link USER_TASKS_THREAD_ID}), pinned to
* the top. This is the only board editable here — users create, move,
* edit, and delete their own cards via the `todos_*` RPC.
* 2. Live agent boards from `chatRuntime.taskBoardByThread` (updated in
* real-time while a conversation runs via socket events).
* 3. Persisted agent boards fetched once on mount from
* `threadApi.listTurnStates` (each turn state may carry a `taskBoard`).
*
* Thread titles are resolved from `thread.threads` when available. Boards
* from threads not present in the list fall back to a shortened thread id.
*
* This component is read-only — moves are not surfaced here; the user manages
* task cards from the Conversations page where the write path lives.
* Agent boards (2 + 3) stay read-only here — those cards are managed from
* the Conversations page where the agent write path lives.
*/
import debug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import { LuPlus } from 'react-icons/lu';
import { useT } from '../../lib/i18n/I18nContext';
import { TaskKanbanBoard } from '../../pages/conversations/components/TaskKanbanBoard';
import { threadApi } from '../../services/api/threadApi';
import { todosApi, USER_TASKS_THREAD_ID } from '../../services/api/todosApi';
import { useAppSelector } from '../../store/hooks';
import type { TaskBoard } from '../../types/turnState';
import type { TaskBoard, TaskBoardCard, TaskBoardCardStatus } from '../../types/turnState';
import { UserTaskComposer } from './UserTaskComposer';
const log = debug('intelligence:tasks');
@@ -41,13 +44,15 @@ export default function IntelligenceTasksTab() {
const threads = useAppSelector(state => state.thread.threads ?? []);
const [persistedBoards, setPersistedBoards] = useState<Record<string, TaskBoard>>({});
const [personalBoard, setPersonalBoard] = useState<TaskBoard | null>(null);
const [composerOpen, setComposerOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const mountedRef = useRef(true);
const fetchPersistedBoards = useCallback(async () => {
log('fetchPersistedBoards: entry');
setLoading(true);
setError(null);
try {
const turnStates = await threadApi.listTurnStates();
@@ -56,11 +61,6 @@ export default function IntelligenceTasksTab() {
for (const ts of turnStates) {
if (ts.taskBoard && ts.taskBoard.cards.length > 0) {
boards[ts.threadId] = ts.taskBoard;
log(
'fetchPersistedBoards: board threadId=%s cards=%d',
ts.threadId,
ts.taskBoard.cards.length
);
}
}
if (mountedRef.current) {
@@ -70,32 +70,157 @@ export default function IntelligenceTasksTab() {
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log('fetchPersistedBoards: error %s', msg);
if (mountedRef.current) setError(msg);
}
}, []);
const fetchPersonalBoard = useCallback(async () => {
log('fetchPersonalBoard: entry');
try {
const board = await todosApi.list(USER_TASKS_THREAD_ID);
if (mountedRef.current) {
setError(msg);
setPersonalBoard(board);
log('fetchPersonalBoard: cards=%d', board.cards.length);
}
} finally {
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log('fetchPersonalBoard: error %s', msg);
// A missing personal board is expected on first run — fall back to
// an empty board so the create affordance still has a home.
if (mountedRef.current) {
setLoading(false);
setPersonalBoard({ threadId: USER_TASKS_THREAD_ID, cards: [], updatedAt: '' });
}
}
}, []);
const loadAll = useCallback(async () => {
// `loading` defaults to true; flip it off once both fetches settle.
await Promise.allSettled([fetchPersistedBoards(), fetchPersonalBoard()]);
if (mountedRef.current) setLoading(false);
}, [fetchPersistedBoards, fetchPersonalBoard]);
useEffect(() => {
mountedRef.current = true;
fetchPersistedBoards();
void loadAll();
return () => {
mountedRef.current = false;
};
}, [fetchPersistedBoards]);
}, [loadAll]);
// A task created from the composer lands either on the personal board or
// on a chosen conversation thread. `add` returns the updated board, so we
// merge it directly — re-fetching listTurnStates would return a stale
// turn-state snapshot that doesn't reflect the just-added card.
const handleCreated = useCallback((threadId: string, board: TaskBoard) => {
log('handleCreated threadId=%s cards=%d', threadId, board.cards.length);
if (threadId === USER_TASKS_THREAD_ID) {
setPersonalBoard(board);
} else {
setPersistedBoards(prev => ({ ...prev, [threadId]: board }));
}
}, []);
// ── personal-board mutations (optimistic, with rollback) ─────────────
const mutatePersonal = useCallback(
async (optimistic: TaskBoard, call: () => Promise<TaskBoard>, previous: TaskBoard) => {
setActionError(null);
setPersonalBoard(optimistic);
try {
const saved = await call();
if (mountedRef.current) setPersonalBoard(saved);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log('personal board mutation failed: %s', msg);
if (mountedRef.current) {
setPersonalBoard(previous);
setActionError(t('conversations.taskKanban.updateFailed'));
}
}
},
[t]
);
const handleMovePersonal = useCallback(
(card: TaskBoardCard, status: TaskBoardCardStatus) => {
if (!personalBoard) return;
const now = new Date().toISOString();
const optimistic: TaskBoard = {
...personalBoard,
cards: personalBoard.cards.map(c =>
c.id === card.id ? { ...c, status, updatedAt: now } : c
),
updatedAt: now,
};
void mutatePersonal(
optimistic,
() => todosApi.updateStatus(USER_TASKS_THREAD_ID, card.id, status),
personalBoard
);
},
[personalBoard, mutatePersonal]
);
const handleUpdatePersonal = useCallback(
(card: TaskBoardCard, nextCard: TaskBoardCard) => {
if (!personalBoard) return;
const now = new Date().toISOString();
const optimistic: TaskBoard = {
...personalBoard,
cards: personalBoard.cards.map(c =>
c.id === card.id ? { ...nextCard, updatedAt: now } : c
),
updatedAt: now,
};
void mutatePersonal(
optimistic,
() =>
todosApi.edit({
threadId: USER_TASKS_THREAD_ID,
id: card.id,
content: nextCard.title,
status: nextCard.status,
objective: nextCard.objective ?? null,
notes: nextCard.notes ?? null,
blocker: nextCard.blocker ?? null,
assignedAgent: nextCard.assignedAgent ?? null,
approvalMode: nextCard.approvalMode ?? null,
plan: nextCard.plan ?? [],
allowedTools: nextCard.allowedTools ?? [],
acceptanceCriteria: nextCard.acceptanceCriteria ?? [],
evidence: nextCard.evidence ?? [],
}),
personalBoard
);
},
[personalBoard, mutatePersonal]
);
const handleDeletePersonal = useCallback(
(card: TaskBoardCard) => {
if (!personalBoard) return;
const optimistic: TaskBoard = {
...personalBoard,
cards: personalBoard.cards.filter(c => c.id !== card.id),
updatedAt: new Date().toISOString(),
};
void mutatePersonal(
optimistic,
() => todosApi.remove(USER_TASKS_THREAD_ID, card.id),
personalBoard
);
},
[personalBoard, mutatePersonal]
);
// ── derived agent board list (read-only) ─────────────────────────────
// Build the merged, deduplicated board list. Live boards take priority
// over persisted ones for the same thread (they reflect the latest agent
// turn in progress).
const threadMap = new Map(threads.map(th => [th.id, th]));
const allThreadIds = new Set([...Object.keys(liveBoards), ...Object.keys(persistedBoards)]);
const boardEntries: ThreadTaskBoard[] = [];
for (const threadId of allThreadIds) {
if (threadId === USER_TASKS_THREAD_ID) continue; // personal board rendered separately
const liveBoard = liveBoards[threadId];
const persistedBoard = persistedBoards[threadId];
const board = liveBoard ?? persistedBoard;
@@ -110,50 +235,77 @@ export default function IntelligenceTasksTab() {
boardEntries.push({ threadId, title, board, live: Boolean(liveBoard) });
}
// Sort: live boards first, then by most-recently-updated.
boardEntries.sort((a, b) => {
if (a.live !== b.live) return a.live ? -1 : 1;
return b.board.updatedAt.localeCompare(a.board.updatedAt);
});
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-stone-400 dark:text-neutral-500">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent mr-2" />
<span className="text-sm">{t('intelligence.tasks.loadingBoards')}</span>
</div>
);
}
if (error) {
return (
<div className="rounded-xl border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-700 dark:text-coral-300">
{t('intelligence.tasks.failedToLoad')}: {error}
</div>
);
}
if (boardEntries.length === 0) {
return (
<div className="flex flex-col items-center gap-3 py-12 text-center text-stone-400 dark:text-neutral-500">
<div className="text-3xl">📋</div>
<p className="text-sm font-medium">{t('intelligence.tasks.empty')}</p>
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('intelligence.tasks.emptyHint')}
</p>
</div>
);
}
const boardCount = boardEntries.length;
const boardCountLabel =
boardCount === 1
? t('intelligence.tasks.activeBoardOne')
: t('intelligence.tasks.activeBoardOther').replace('{count}', String(boardCount));
const personalCards = personalBoard?.cards ?? [];
return (
<div className="space-y-6">
<p className="text-xs text-stone-400 dark:text-neutral-500">{boardCountLabel}</p>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-stone-400 dark:text-neutral-500">
{t('intelligence.tasks.subtitle')}
</p>
<button
type="button"
onClick={() => setComposerOpen(true)}
className="inline-flex flex-none items-center gap-1.5 rounded-md bg-ocean-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-700">
<LuPlus className="h-3.5 w-3.5" />
{t('intelligence.tasks.newTask')}
</button>
</div>
{actionError && (
<div className="rounded-xl border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-700 dark:text-coral-300">
{actionError}
</div>
)}
{/* Personal board — always present so users can manage their own tasks. */}
<section className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="truncate text-sm font-semibold text-stone-700 dark:text-neutral-200">
{t('intelligence.tasks.personalBoardTitle')}
</h3>
</div>
{personalCards.length > 0 ? (
<TaskKanbanBoard
board={personalBoard as TaskBoard}
hideHeader
onMove={handleMovePersonal}
onUpdateCard={handleUpdatePersonal}
onDeleteCard={handleDeletePersonal}
/>
) : (
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-stone-200 dark:border-neutral-800 py-8 text-center text-stone-400 dark:text-neutral-500">
<p className="text-sm font-medium">{t('intelligence.tasks.personalEmpty')}</p>
<button
type="button"
onClick={() => setComposerOpen(true)}
className="inline-flex items-center gap-1.5 text-xs font-medium text-ocean-600 hover:text-ocean-700 dark:text-ocean-300 dark:hover:text-ocean-200">
<LuPlus className="h-3.5 w-3.5" />
{t('intelligence.tasks.newTask')}
</button>
</div>
)}
</section>
{loading && (
<div className="flex items-center justify-center py-6 text-stone-400 dark:text-neutral-500">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent mr-2" />
<span className="text-sm">{t('intelligence.tasks.loadingBoards')}</span>
</div>
)}
{error && (
<div className="rounded-xl border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-4 py-3 text-sm text-coral-700 dark:text-coral-300">
{t('intelligence.tasks.failedToLoad')}: {error}
</div>
)}
{/* Agent / conversation boards — read-only. */}
{boardEntries.map(entry => (
<section key={entry.threadId} className="space-y-2">
<div className="flex items-center gap-2">
@@ -169,9 +321,14 @@ export default function IntelligenceTasksTab() {
</span>
)}
</div>
<TaskKanbanBoard board={entry.board} />
<TaskKanbanBoard board={entry.board} hideHeader />
</section>
))}
{composerOpen && (
<UserTaskComposer onCreated={handleCreated} onClose={() => setComposerOpen(false)} />
)}
</div>
);
}
@@ -0,0 +1,99 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { todosApi, USER_TASKS_THREAD_ID } from '../../services/api/todosApi';
import { UserTaskComposer } from './UserTaskComposer';
vi.mock('../../store/hooks', () => ({
useAppSelector: (sel: (state: unknown) => unknown) =>
sel({
thread: {
threads: [
{ id: 't-1', title: 'Plan trip' },
{ id: 'worker-1', title: 'Worker', parentThreadId: 't-1' },
],
},
}),
}));
vi.mock('../../services/api/todosApi', () => ({
USER_TASKS_THREAD_ID: 'user-tasks',
todosApi: { add: vi.fn() },
}));
const mockAdd = vi.mocked(todosApi.add);
function emptyBoard(threadId: string) {
return { threadId, cards: [], updatedAt: '' };
}
describe('UserTaskComposer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('disables Create until a title is entered', () => {
render(<UserTaskComposer onCreated={vi.fn()} onClose={vi.fn()} />);
const createBtn = screen.getByRole('button', { name: 'Create task' });
expect(createBtn).toBeDisabled();
fireEvent.change(screen.getByPlaceholderText('What needs to be done?'), {
target: { value: 'Buy milk' },
});
expect(createBtn).toBeEnabled();
});
it('creates a task on the personal board by default', async () => {
mockAdd.mockResolvedValueOnce(emptyBoard(USER_TASKS_THREAD_ID));
const onCreated = vi.fn();
const onClose = vi.fn();
render(<UserTaskComposer onCreated={onCreated} onClose={onClose} />);
fireEvent.change(screen.getByPlaceholderText('What needs to be done?'), {
target: { value: 'Buy milk' },
});
fireEvent.click(screen.getByRole('button', { name: 'Create task' }));
await waitFor(() => expect(mockAdd).toHaveBeenCalledTimes(1));
expect(mockAdd).toHaveBeenCalledWith({
threadId: USER_TASKS_THREAD_ID,
content: 'Buy milk',
status: 'todo',
objective: null,
notes: null,
});
expect(onCreated).toHaveBeenCalledWith(USER_TASKS_THREAD_ID, expect.any(Object));
expect(onClose).toHaveBeenCalledTimes(1);
});
it('attaches the task to a chosen conversation', async () => {
mockAdd.mockResolvedValueOnce(emptyBoard('t-1'));
render(<UserTaskComposer onCreated={vi.fn()} onClose={vi.fn()} />);
fireEvent.change(screen.getByPlaceholderText('What needs to be done?'), {
target: { value: 'Book hotel' },
});
// The attach selector lists user-initiated threads (worker threads excluded).
expect(screen.queryByRole('option', { name: 'Worker' })).not.toBeInTheDocument();
fireEvent.change(screen.getByDisplayValue('Personal (no conversation)'), {
target: { value: 't-1' },
});
fireEvent.click(screen.getByRole('button', { name: 'Create task' }));
await waitFor(() => expect(mockAdd).toHaveBeenCalledTimes(1));
expect(mockAdd.mock.calls[0][0].threadId).toBe('t-1');
});
it('surfaces an error and keeps the modal open on failure', async () => {
mockAdd.mockRejectedValueOnce(new Error('boom'));
const onClose = vi.fn();
render(<UserTaskComposer onCreated={vi.fn()} onClose={onClose} />);
fireEvent.change(screen.getByPlaceholderText('What needs to be done?'), {
target: { value: 'Buy milk' },
});
fireEvent.click(screen.getByRole('button', { name: 'Create task' }));
await waitFor(() => expect(screen.getByText(/Couldn't create the task/)).toBeInTheDocument());
expect(onClose).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,198 @@
/**
* UserTaskComposer — modal form for creating a user-owned task.
*
* Tasks default to the personal board ({@link USER_TASKS_THREAD_ID}) and
* are *optionally* attachable to an existing conversation thread. On
* submit it calls `todosApi.add` and hands the resulting board back to the
* parent via `onCreated` so the Tasks tab can refresh in place.
*/
import debug from 'debug';
import { useState } from 'react';
import { LuX } from 'react-icons/lu';
import { useT } from '../../lib/i18n/I18nContext';
import { todosApi, USER_TASKS_THREAD_ID } from '../../services/api/todosApi';
import { useAppSelector } from '../../store/hooks';
import type { TaskBoard, TaskBoardCardStatus } from '../../types/turnState';
const log = debug('intelligence:task-composer');
// Tasks use three states only: Pending / Working / Done.
const STATUS_OPTIONS: { value: TaskBoardCardStatus; labelKey: string }[] = [
{ value: 'todo', labelKey: 'conversations.taskKanban.pending' },
{ value: 'in_progress', labelKey: 'conversations.taskKanban.working' },
{ value: 'done', labelKey: 'conversations.taskKanban.done' },
];
interface UserTaskComposerProps {
/** Called with the updated board for the thread the task landed on. */
onCreated: (threadId: string, board: TaskBoard) => void;
onClose: () => void;
}
export function UserTaskComposer({ onCreated, onClose }: UserTaskComposerProps) {
const { t } = useT();
const threads = useAppSelector(state => state.thread.threads ?? []);
const [title, setTitle] = useState('');
const [status, setStatus] = useState<TaskBoardCardStatus>('todo');
const [objective, setObjective] = useState('');
const [notes, setNotes] = useState('');
const [attachThreadId, setAttachThreadId] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Only user-initiated conversations are attachable; background
// worker/subagent threads (those with a parent) would be confusing
// targets for a manual task.
const attachableThreads = threads.filter(thread => !thread.parentThreadId);
const canSubmit = title.trim().length > 0 && !submitting;
const handleSubmit = async () => {
const trimmedTitle = title.trim();
if (!trimmedTitle || submitting) return;
const threadId = attachThreadId || USER_TASKS_THREAD_ID;
setSubmitting(true);
setError(null);
log('submit threadId=%s status=%s', threadId, status);
try {
const board = await todosApi.add({
threadId,
content: trimmedTitle,
status,
objective: objective.trim() || null,
notes: notes.trim() || null,
});
onCreated(threadId, board);
onClose();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log('submit failed: %s', msg);
setError(msg);
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 px-4 py-6">
<section className="max-h-full w-full max-w-lg overflow-y-auto rounded-lg border border-stone-200 bg-white p-4 shadow-xl dark:border-neutral-800 dark:bg-neutral-900">
<div className="mb-3 flex items-start justify-between gap-3">
<h3 className="text-base font-semibold text-stone-900 dark:text-neutral-50">
{t('intelligence.tasks.composer.title')}
</h3>
<button
type="button"
aria-label={t('common.cancel')}
onClick={onClose}
className="flex h-7 w-7 flex-none items-center justify-center rounded-md text-stone-500 hover:bg-stone-100 hover:text-stone-800 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-100">
<LuX className="h-4 w-4" />
</button>
</div>
<div className="space-y-3 text-sm">
<label className="block">
<span className="mb-1 block text-xs font-semibold text-stone-500 dark:text-neutral-400">
{t('intelligence.tasks.composer.titleLabel')}
</span>
<input
autoFocus
value={title}
onChange={e => setTitle(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) handleSubmit();
}}
placeholder={t('intelligence.tasks.composer.titlePlaceholder')}
className="w-full rounded-md border border-stone-200 bg-white px-2 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-50"
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="mb-1 block text-xs font-semibold text-stone-500 dark:text-neutral-400">
{t('intelligence.tasks.composer.statusLabel')}
</span>
<select
value={status}
onChange={e => setStatus(e.target.value as TaskBoardCardStatus)}
className="w-full rounded-md border border-stone-200 bg-white px-2 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-50">
{STATUS_OPTIONS.map(option => (
<option key={option.value} value={option.value}>
{t(option.labelKey)}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1 block text-xs font-semibold text-stone-500 dark:text-neutral-400">
{t('intelligence.tasks.composer.attachLabel')}
</span>
<select
value={attachThreadId}
onChange={e => setAttachThreadId(e.target.value)}
className="w-full rounded-md border border-stone-200 bg-white px-2 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-50">
<option value="">{t('intelligence.tasks.composer.attachNone')}</option>
{attachableThreads.map(thread => (
<option key={thread.id} value={thread.id}>
{thread.title?.trim() || thread.id}
</option>
))}
</select>
</label>
</div>
<label className="block">
<span className="mb-1 block text-xs font-semibold text-stone-500 dark:text-neutral-400">
{t('intelligence.tasks.composer.objectiveLabel')}
</span>
<input
value={objective}
onChange={e => setObjective(e.target.value)}
placeholder={t('intelligence.tasks.composer.objectivePlaceholder')}
className="w-full rounded-md border border-stone-200 bg-white px-2 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-50"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-semibold text-stone-500 dark:text-neutral-400">
{t('intelligence.tasks.composer.notesLabel')}
</span>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
rows={3}
placeholder={t('intelligence.tasks.composer.notesPlaceholder')}
className="w-full resize-y rounded-md border border-stone-200 bg-white px-2 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-50"
/>
</label>
{error && (
<p className="rounded-md border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
{t('intelligence.tasks.composer.createFailed')}: {error}
</p>
)}
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
className="rounded-md border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800">
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
className="rounded-md bg-ocean-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-700 disabled:opacity-50">
{submitting
? t('intelligence.tasks.composer.creating')
: t('intelligence.tasks.composer.create')}
</button>
</div>
</div>
</section>
</div>
);
}
@@ -4,7 +4,6 @@ import { describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../test/test-utils';
import type { ActionableItem, TimeGroup } from '../../../types/intelligence';
import IntelligenceDreamsTab from '../IntelligenceDreamsTab';
import IntelligenceMemoryTab from '../IntelligenceMemoryTab';
vi.mock('../ActionableCard', () => ({
@@ -59,14 +58,6 @@ function renderMemoryTab(
}
describe('Intelligence tab panels', () => {
it('renders the dreams placeholder copy', () => {
renderWithProviders(<IntelligenceDreamsTab />);
expect(screen.getByRole('heading', { name: 'Dreams' })).toBeInTheDocument();
expect(screen.getByText(/generate a dream|AI-generated reflections/i)).toBeInTheDocument();
expect(screen.getByText('Coming soon')).toBeInTheDocument();
});
it('wires search and source filters', () => {
const props = renderMemoryTab();
@@ -2,19 +2,25 @@
* Vitest for IntelligenceTasksTab.
*
* Covers:
* - Loading state while listTurnStates is in-flight.
* - Loading state while the boards are in-flight.
* - Error state when listTurnStates rejects.
* - Empty state when no boards have any cards.
* - Board aggregation: persisted boards from turn-state list are shown.
* - Live boards from Redux take priority and render a "live" badge.
* - Thread title resolution: threads with a title use it; unknown threads
* fall back to a shortened thread id.
* - The personal board ({@link USER_TASKS_THREAD_ID}) is always shown, with
* an empty-state CTA when it has no cards, and is editable (move/delete)
* and refreshable from the create composer.
* - Agent board aggregation: persisted boards from the turn-state list are
* shown read-only; live boards from Redux take priority + a "live" badge.
* - Thread title resolution for agent boards.
*/
import { screen, waitFor } from '@testing-library/react';
import { fireEvent, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
listTurnStates: vi.fn(),
todosList: vi.fn(),
todosAdd: vi.fn(),
todosEdit: vi.fn(),
todosUpdateStatus: vi.fn(),
todosRemove: vi.fn(),
selectorResult: {
chatRuntime: { taskBoardByThread: {} as Record<string, unknown> },
thread: { threads: [] as unknown[] },
@@ -25,21 +31,77 @@ vi.mock('../../../services/api/threadApi', () => ({
threadApi: { listTurnStates: hoisted.listTurnStates },
}));
vi.mock('../../../services/api/todosApi', () => ({
USER_TASKS_THREAD_ID: 'user-tasks',
todosApi: {
list: hoisted.todosList,
add: hoisted.todosAdd,
edit: hoisted.todosEdit,
updateStatus: hoisted.todosUpdateStatus,
remove: hoisted.todosRemove,
},
}));
vi.mock('../../../store/hooks', () => ({
useAppSelector: (selector: (state: typeof hoisted.selectorResult) => unknown) =>
selector(hoisted.selectorResult),
useAppDispatch: () => vi.fn(),
}));
// TaskKanbanBoard is exercised by its own test; stub it to a simple
// table so we can assert on title/card-count without rendering the
// full kanban grid.
// Stub the composer so we can drive its `onCreated` callback without
// exercising its internals.
vi.mock('../UserTaskComposer', () => ({
UserTaskComposer: ({ onCreated }: { onCreated: (threadId: string, board: unknown) => void }) => (
<div data-testid="composer">
<button
type="button"
onClick={() =>
onCreated('user-tasks', {
threadId: 'user-tasks',
cards: [
{
id: 'created-0',
title: 'Created card',
status: 'todo',
order: 0,
updatedAt: '2026-01-01T00:00:00Z',
},
],
updatedAt: '2026-01-01T00:00:00Z',
})
}>
stub-create
</button>
</div>
),
}));
// Stub the kanban to a simple list that still surfaces the write callbacks
// the personal board wires up, so we can assert the todos RPC is called.
vi.mock('../../../pages/conversations/components/TaskKanbanBoard', () => ({
TaskKanbanBoard: ({ board }: { board: { cards: { title: string }[] } }) => (
TaskKanbanBoard: ({
board,
onMove,
onDeleteCard,
}: {
board: { cards: { id: string; title: string; status: string }[] };
onMove?: (card: unknown, status: string) => void;
onDeleteCard?: (card: unknown) => void;
}) => (
<div data-testid="kanban-stub">
{board.cards.map(c => (
<span key={c.title}>{c.title}</span>
<span key={c.id}>{c.title}</span>
))}
{onMove && (
<button type="button" onClick={() => onMove(board.cards[0], 'in_progress')}>
stub-move
</button>
)}
{onDeleteCard && (
<button type="button" onClick={() => onDeleteCard(board.cards[0])}>
stub-delete
</button>
)}
</div>
),
}));
@@ -72,13 +134,20 @@ describe('IntelligenceTasksTab', () => {
beforeEach(() => {
vi.resetModules();
hoisted.listTurnStates.mockReset();
hoisted.todosList.mockReset();
hoisted.todosAdd.mockReset();
hoisted.todosEdit.mockReset();
hoisted.todosUpdateStatus.mockReset();
hoisted.todosRemove.mockReset();
hoisted.selectorResult.chatRuntime.taskBoardByThread = {};
hoisted.selectorResult.thread.threads = [];
// Sensible defaults: empty personal board, no agent boards.
hoisted.listTurnStates.mockResolvedValue([]);
hoisted.todosList.mockResolvedValue(makeBoard('user-tasks', []));
});
test('shows loading spinner while fetching', async () => {
// Never resolves during this test
hoisted.listTurnStates.mockReturnValue(new Promise(() => {}));
hoisted.listTurnStates.mockReturnValue(new Promise(() => {})); // never resolves
vi.resetModules();
const Tab = await importTab();
renderTab(Tab);
@@ -95,20 +164,18 @@ describe('IntelligenceTasksTab', () => {
});
});
test('shows empty-state when no boards have cards', async () => {
hoisted.listTurnStates.mockResolvedValue([
{ threadId: 'thread-a', taskBoard: null },
{ threadId: 'thread-b', taskBoard: makeBoard('thread-b', []) },
]);
test('always shows the personal board with an empty-state CTA', async () => {
vi.resetModules();
const Tab = await importTab();
renderTab(Tab);
await waitFor(() => {
expect(screen.getByText(/no agent task boards yet/i)).toBeInTheDocument();
expect(screen.getByText('No personal tasks yet')).toBeInTheDocument();
});
expect(screen.getByText('Agent Tasks')).toBeInTheDocument();
expect(screen.getAllByRole('button', { name: /New task/ }).length).toBeGreaterThan(0);
});
test('renders persisted boards from turn-state list', async () => {
test('renders persisted agent boards from the turn-state list', async () => {
hoisted.listTurnStates.mockResolvedValue([
{ threadId: 'thread-x', taskBoard: makeBoard('thread-x', ['Write docs', 'Fix bug']) },
]);
@@ -116,9 +183,8 @@ describe('IntelligenceTasksTab', () => {
const Tab = await importTab();
renderTab(Tab);
await waitFor(() => {
expect(screen.getByTestId('kanban-stub')).toBeInTheDocument();
expect(screen.getByText('Write docs')).toBeInTheDocument();
});
expect(screen.getByText('Write docs')).toBeInTheDocument();
expect(screen.getByText('Fix bug')).toBeInTheDocument();
});
@@ -137,21 +203,6 @@ describe('IntelligenceTasksTab', () => {
});
});
test('falls back to shortened thread id when title is missing', async () => {
hoisted.listTurnStates.mockResolvedValue([
{ threadId: 'abcdef1234567890', taskBoard: makeBoard('abcdef1234567890', ['Plan']) },
]);
// No entry in thread list
hoisted.selectorResult.thread.threads = [];
vi.resetModules();
const Tab = await importTab();
renderTab(Tab);
await waitFor(() => {
// Shortened id — last 8 chars are "34567890"
expect(screen.getByText(/34567890/)).toBeInTheDocument();
});
});
test('live boards from Redux take priority and show "live" badge', async () => {
hoisted.listTurnStates.mockResolvedValue([
{ threadId: 'thread-live', taskBoard: makeBoard('thread-live', ['Old card']) },
@@ -165,20 +216,49 @@ describe('IntelligenceTasksTab', () => {
await waitFor(() => {
expect(screen.getByText('Live card')).toBeInTheDocument();
});
// The live badge is present
expect(screen.getByText('live')).toBeInTheDocument();
});
test('shows count of active boards', async () => {
hoisted.listTurnStates.mockResolvedValue([
{ threadId: 'ta', taskBoard: makeBoard('ta', ['A']) },
{ threadId: 'tb', taskBoard: makeBoard('tb', ['B']) },
]);
test('renders personal cards and moves one via the todos RPC', async () => {
hoisted.todosList.mockResolvedValue(makeBoard('user-tasks', ['My personal task']));
hoisted.todosUpdateStatus.mockResolvedValue(makeBoard('user-tasks', ['My personal task']));
vi.resetModules();
const Tab = await importTab();
renderTab(Tab);
await waitFor(() => {
expect(screen.getByText(/2 active boards/i)).toBeInTheDocument();
expect(screen.getByText('My personal task')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('stub-move'));
await waitFor(() => expect(hoisted.todosUpdateStatus).toHaveBeenCalledTimes(1));
expect(hoisted.todosUpdateStatus).toHaveBeenCalledWith('user-tasks', 'card-0', 'in_progress');
});
test('deletes a personal card via the todos RPC', async () => {
hoisted.todosList.mockResolvedValue(makeBoard('user-tasks', ['Disposable']));
hoisted.todosRemove.mockResolvedValue(makeBoard('user-tasks', []));
vi.resetModules();
const Tab = await importTab();
renderTab(Tab);
await waitFor(() => {
expect(screen.getByText('Disposable')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('stub-delete'));
await waitFor(() => expect(hoisted.todosRemove).toHaveBeenCalledTimes(1));
expect(hoisted.todosRemove).toHaveBeenCalledWith('user-tasks', 'card-0');
});
test('opens the composer and applies the created personal board', async () => {
vi.resetModules();
const Tab = await importTab();
renderTab(Tab);
await waitFor(() => expect(screen.getByText('Agent Tasks')).toBeInTheDocument());
fireEvent.click(screen.getAllByRole('button', { name: /New task/ })[0]);
expect(screen.getByTestId('composer')).toBeInTheDocument();
fireEvent.click(screen.getByText('stub-create'));
await waitFor(() => {
expect(screen.getByText('Created card')).toBeInTheDocument();
});
});
});
@@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
export type SettingsRoute =
| 'home'
| 'agents'
| 'account'
| 'features'
| 'messaging'
@@ -119,6 +120,7 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
if (path.includes('/settings/mascot')) return 'mascot';
if (path.includes('/settings/persona')) return 'persona';
if (path.includes('/settings/appearance')) return 'appearance';
if (path.includes('/settings/agents')) return 'agents';
if (path.includes('/settings/mcp-server')) return 'mcp-server';
if (path.includes('/settings/dev-workflow')) return 'dev-workflow';
return 'home';
@@ -0,0 +1,91 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { agentRegistryApi, type AgentRegistryEntry } from '../../../services/api/agentRegistryApi';
import AgentsPanel from './AgentsPanel';
vi.mock('../../../services/api/agentRegistryApi', () => ({
agentRegistryApi: {
list: vi.fn(),
get: vi.fn(),
createCustom: vi.fn(),
update: vi.fn(),
setEnabled: vi.fn(),
remove: vi.fn(),
},
}));
vi.mock('../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: vi.fn() }),
}));
vi.mock('../SettingsHeader', () => ({
default: ({ title }: { title: string }) => <h1>{title}</h1>,
}));
const mockList = vi.mocked(agentRegistryApi.list);
const mockSetEnabled = vi.mocked(agentRegistryApi.setEnabled);
function agent(overrides: Partial<AgentRegistryEntry> = {}): AgentRegistryEntry {
return {
id: 'researcher',
name: 'Researcher',
description: 'Looks things up.',
source: 'default',
enabled: true,
tool_allowlist: ['*'],
...overrides,
};
}
describe('AgentsPanel', () => {
beforeEach(() => {
vi.clearAllMocks();
mockList.mockResolvedValue([
agent({ id: 'orchestrator', name: 'Orchestrator' }),
agent({ id: 'researcher', name: 'Researcher' }),
agent({
id: 'finance',
name: 'Finance',
source: 'custom',
tool_allowlist: ['memory.search'],
}),
]);
});
it('lists agents with their source badges', async () => {
render(<AgentsPanel />);
await waitFor(() => expect(screen.getByText('Researcher')).toBeInTheDocument());
expect(screen.getByText('Orchestrator')).toBeInTheDocument();
expect(screen.getByText('Finance')).toBeInTheDocument();
expect(screen.getByText('Custom')).toBeInTheDocument();
expect(screen.getAllByText('Built-in').length).toBe(2);
});
it('toggles a non-orchestrator agent via setEnabled', async () => {
mockSetEnabled.mockResolvedValue(agent({ id: 'researcher', enabled: false }));
render(<AgentsPanel />);
await waitFor(() => expect(screen.getByText('Researcher')).toBeInTheDocument());
const switches = screen.getAllByRole('switch');
// Order matches list order: [orchestrator, researcher, finance].
expect(switches[0]).toBeDisabled(); // orchestrator is always enabled
fireEvent.click(switches[1]);
await waitFor(() => expect(mockSetEnabled).toHaveBeenCalledWith('researcher', false));
});
it('opens the create editor', async () => {
render(<AgentsPanel />);
await waitFor(() => expect(screen.getByText('Researcher')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /New agent/ }));
expect(screen.getByRole('button', { name: 'Create agent' })).toBeInTheDocument();
expect(screen.getByText('ID')).toBeInTheDocument();
});
it('shows an error when loading fails', async () => {
mockList.mockRejectedValueOnce(new Error('boom'));
render(<AgentsPanel />);
await waitFor(() => expect(screen.getByText(/Couldn't load agents/)).toBeInTheDocument());
});
});
@@ -0,0 +1,472 @@
/**
* AgentsPanel — Settings > Agents.
*
* Surfaces the user-facing agent registry (`openhuman.agent_registry_*`):
* shipped built-in agents plus user-authored custom agents. Users can
* enable/disable agents, create custom agents, edit any agent (editing a
* built-in saves an override), and delete a custom agent / reset a built-in
* override.
*/
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { LuPencil, LuPlus, LuRotateCcw, LuTrash2 } from 'react-icons/lu';
import { useT } from '../../../lib/i18n/I18nContext';
import {
agentRegistryApi,
type AgentRegistryEntry,
type UpdateAgentInput,
} from '../../../services/api/agentRegistryApi';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
const ORCHESTRATOR_ID = 'orchestrator';
function slugify(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function splitLines(value: string): string[] {
return value
.split('\n')
.map(line => line.trim())
.filter(Boolean);
}
const AgentsPanel = () => {
const { t } = useT();
const { navigateBack } = useSettingsNavigation();
const [agents, setAgents] = useState<AgentRegistryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [editing, setEditing] = useState<AgentRegistryEntry | null>(null);
const [creating, setCreating] = useState(false);
const mountedRef = useRef(true);
const load = useCallback(async () => {
setError(null);
try {
const list = await agentRegistryApi.list(true);
if (mountedRef.current) setAgents(list);
} catch (err) {
if (mountedRef.current) setError(err instanceof Error ? err.message : String(err));
} finally {
if (mountedRef.current) setLoading(false);
}
}, []);
useEffect(() => {
mountedRef.current = true;
void load();
return () => {
mountedRef.current = false;
};
}, [load]);
const handleToggle = useCallback(
async (agent: AgentRegistryEntry) => {
if (agent.id === ORCHESTRATOR_ID) return;
setActionError(null);
setBusyId(agent.id);
try {
const updated = await agentRegistryApi.setEnabled(agent.id, !agent.enabled);
if (mountedRef.current) {
setAgents(prev => prev.map(a => (a.id === updated.id ? updated : a)));
}
} catch (err) {
if (mountedRef.current) {
setActionError(err instanceof Error ? err.message : t('settings.agents.actionFailed'));
}
} finally {
if (mountedRef.current) setBusyId(null);
}
},
[t]
);
const handleRemove = useCallback(
async (agent: AgentRegistryEntry) => {
setActionError(null);
setBusyId(agent.id);
try {
await agentRegistryApi.remove(agent.id);
await load();
} catch (err) {
if (mountedRef.current) {
setActionError(err instanceof Error ? err.message : t('settings.agents.actionFailed'));
}
} finally {
if (mountedRef.current) setBusyId(null);
}
},
[load, t]
);
const handleSaved = useCallback((saved: AgentRegistryEntry) => {
setAgents(prev => {
const exists = prev.some(a => a.id === saved.id);
return exists ? prev.map(a => (a.id === saved.id ? saved : a)) : [...prev, saved];
});
setEditing(null);
setCreating(false);
}, []);
return (
<div className="mx-auto max-w-3xl px-4 py-6">
<SettingsHeader title={t('settings.agents.title')} onBack={navigateBack} />
<div className="mb-4 flex items-start justify-between gap-3">
<p className="text-sm text-stone-500 dark:text-neutral-400">
{t('settings.agents.subtitle')}
</p>
<button
type="button"
onClick={() => setCreating(true)}
className="inline-flex flex-none items-center gap-1.5 rounded-md bg-ocean-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-700">
<LuPlus className="h-3.5 w-3.5" />
{t('settings.agents.newAgent')}
</button>
</div>
{actionError && (
<div className="mb-3 rounded-lg border border-coral-200 bg-coral-50 px-3 py-2 text-sm text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
{actionError}
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-12 text-stone-400 dark:text-neutral-500">
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent" />
<span className="text-sm">{t('common.loading')}</span>
</div>
) : error ? (
<div className="rounded-lg border border-coral-200 bg-coral-50 px-4 py-3 text-sm text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
{t('settings.agents.loadError')}: {error}
</div>
) : agents.length === 0 ? (
<p className="py-12 text-center text-sm text-stone-400 dark:text-neutral-500">
{t('settings.agents.empty')}
</p>
) : (
<ul className="space-y-2">
{agents.map(agent => (
<AgentRow
key={agent.id}
agent={agent}
busy={busyId === agent.id}
onToggle={() => handleToggle(agent)}
onEdit={() => setEditing(agent)}
onRemove={() => handleRemove(agent)}
/>
))}
</ul>
)}
{(editing || creating) && (
<AgentEditor
agent={editing}
onClose={() => {
setEditing(null);
setCreating(false);
}}
onSaved={handleSaved}
/>
)}
</div>
);
};
function AgentRow({
agent,
busy,
onToggle,
onEdit,
onRemove,
}: {
agent: AgentRegistryEntry;
busy: boolean;
onToggle: () => void;
onEdit: () => void;
onRemove: () => void;
}) {
const { t } = useT();
const isCustom = agent.source === 'custom';
const isOrchestrator = agent.id === ORCHESTRATOR_ID;
const tools = agent.tool_allowlist ?? [];
const toolsLabel = tools.includes('*')
? t('settings.agents.toolsAll')
: t('settings.agents.toolsCount').replace('{count}', String(tools.length));
return (
<li
className={`rounded-xl border border-stone-200 bg-white px-4 py-3 shadow-sm dark:border-neutral-800 dark:bg-neutral-900 ${
agent.enabled ? '' : 'opacity-70'
}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="truncate text-sm font-semibold text-stone-800 dark:text-neutral-100">
{agent.name}
</h3>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
isCustom
? 'bg-ocean-50 text-ocean-700 dark:bg-ocean-500/10 dark:text-ocean-200'
: 'bg-stone-100 text-stone-600 dark:bg-neutral-800 dark:text-neutral-300'
}`}>
{isCustom ? t('settings.agents.sourceCustom') : t('settings.agents.sourceDefault')}
</span>
</div>
<p className="mt-1 break-words text-xs leading-snug text-stone-500 dark:text-neutral-400">
{agent.description}
</p>
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-stone-400 dark:text-neutral-500">
<code className="font-mono">{agent.id}</code>
{agent.model && (
<span>
{t('settings.agents.modelLabel')}: {agent.model}
</span>
)}
<span>
{t('settings.agents.toolsLabel')}: {toolsLabel}
</span>
</div>
</div>
<div className="flex flex-none items-center gap-2">
<button
type="button"
role="switch"
aria-checked={agent.enabled}
aria-label={agent.enabled ? t('settings.agents.disable') : t('settings.agents.enable')}
disabled={busy || isOrchestrator}
title={isOrchestrator ? t('settings.agents.orchestratorLocked') : undefined}
onClick={onToggle}
className={`relative h-5 w-9 flex-none rounded-full transition-colors disabled:opacity-40 ${
agent.enabled ? 'bg-ocean-600' : 'bg-stone-300 dark:bg-neutral-700'
}`}>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
agent.enabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
</div>
<div className="mt-2 flex items-center justify-end gap-1">
<button
type="button"
onClick={onEdit}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-stone-600 hover:bg-stone-100 dark:text-neutral-300 dark:hover:bg-neutral-800">
<LuPencil className="h-3 w-3" />
{t('settings.agents.edit')}
</button>
<button
type="button"
disabled={busy}
onClick={onRemove}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-coral-600 hover:bg-coral-50 disabled:opacity-40 dark:text-coral-300 dark:hover:bg-coral-500/10">
{isCustom ? <LuTrash2 className="h-3 w-3" /> : <LuRotateCcw className="h-3 w-3" />}
{isCustom ? t('settings.agents.delete') : t('settings.agents.reset')}
</button>
</div>
</li>
);
}
function AgentEditor({
agent,
onClose,
onSaved,
}: {
agent: AgentRegistryEntry | null;
onClose: () => void;
onSaved: (saved: AgentRegistryEntry) => void;
}) {
const { t } = useT();
const isCreate = agent === null;
const isCustom = agent?.source === 'custom';
const [id, setId] = useState(agent?.id ?? '');
const [idTouched, setIdTouched] = useState(!isCreate);
const [name, setName] = useState(agent?.name ?? '');
const [description, setDescription] = useState(agent?.description ?? '');
const [model, setModel] = useState(agent?.model ?? '');
const [systemPrompt, setSystemPrompt] = useState(agent?.system_prompt ?? '');
const [tools, setTools] = useState((agent?.tool_allowlist ?? []).join('\n'));
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Auto-derive id from name while creating, until the user edits it.
const handleName = (value: string) => {
setName(value);
if (isCreate && !idTouched) setId(slugify(value));
};
const canSubmit = name.trim().length > 0 && description.trim().length > 0 && !submitting;
const handleSubmit = async () => {
if (!canSubmit) return;
setSubmitting(true);
setError(null);
try {
const toolAllowlist = splitLines(tools);
let saved: AgentRegistryEntry;
if (isCreate) {
saved = await agentRegistryApi.createCustom({
id: id.trim() || slugify(name),
name: name.trim(),
description: description.trim(),
model: model.trim() || null,
system_prompt: systemPrompt.trim() || null,
tool_allowlist: toolAllowlist,
});
} else {
const patch: UpdateAgentInput = {
name: name.trim(),
description: description.trim(),
model: model.trim() || null,
system_prompt: systemPrompt.trim() || null,
tool_allowlist: toolAllowlist,
};
saved = await agentRegistryApi.update(agent.id, patch);
}
onSaved(saved);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 px-4 py-6">
<section className="max-h-full w-full max-w-lg overflow-y-auto rounded-lg border border-stone-200 bg-white p-4 shadow-xl dark:border-neutral-800 dark:bg-neutral-900">
<h3 className="mb-3 text-base font-semibold text-stone-900 dark:text-neutral-50">
{isCreate
? t('settings.agents.editor.createTitle')
: t('settings.agents.editor.editTitle')}
</h3>
<div className="space-y-3 text-sm">
<Field label={t('settings.agents.editor.name')}>
<input
autoFocus
value={name}
onChange={e => handleName(e.target.value)}
className={inputClass}
/>
</Field>
{isCreate && (
<Field label={t('settings.agents.editor.id')} hint={t('settings.agents.editor.idHint')}>
<input
value={id}
onChange={e => {
setIdTouched(true);
setId(e.target.value);
}}
className={`${inputClass} font-mono`}
/>
</Field>
)}
<Field label={t('settings.agents.editor.description')}>
<input
value={description}
onChange={e => setDescription(e.target.value)}
className={inputClass}
/>
</Field>
<Field label={t('settings.agents.editor.model')}>
<input
value={model ?? ''}
onChange={e => setModel(e.target.value)}
placeholder={t('settings.agents.editor.modelPlaceholder')}
className={inputClass}
/>
</Field>
<Field label={t('settings.agents.editor.systemPrompt')}>
<textarea
value={systemPrompt ?? ''}
onChange={e => setSystemPrompt(e.target.value)}
rows={3}
className={`${inputClass} resize-y`}
/>
</Field>
<Field
label={t('settings.agents.editor.tools')}
hint={t('settings.agents.editor.toolsHint')}>
<textarea
value={tools}
onChange={e => setTools(e.target.value)}
rows={3}
className={`${inputClass} resize-y font-mono`}
/>
</Field>
{!isCreate && !isCustom && (
<p className="text-[11px] text-stone-400 dark:text-neutral-500">
{t('settings.agents.editor.defaultsNote')}
</p>
)}
{error && (
<p className="rounded-md border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
{error}
</p>
)}
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
className="rounded-md border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800">
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
className="rounded-md bg-ocean-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-700 disabled:opacity-50">
{submitting
? t('settings.agents.editor.saving')
: isCreate
? t('settings.agents.editor.create')
: t('settings.agents.editor.save')}
</button>
</div>
</div>
</section>
</div>
);
}
const inputClass =
'w-full rounded-md border border-stone-200 bg-white px-2 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-50';
function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="block">
<span className="mb-1 block text-xs font-semibold text-stone-500 dark:text-neutral-400">
{label}
</span>
{children}
{hint && (
<span className="mt-1 block text-[11px] text-stone-400 dark:text-neutral-500">{hint}</span>
)}
</label>
);
}
export default AgentsPanel;
+3
View File
@@ -175,6 +175,9 @@ const ar1: TranslationMap = {
'memory.noResults': 'لم يتم العثور على ذكريات',
'memory.empty': 'لا توجد ذكريات بعد. تُنشأ الذكريات تلقائيًا أثناء تفاعلك.',
'memory.tab.memory': 'الذاكرة',
'memory.tab.tasks': 'Agent Tasks',
'memory.tab.tasksDescription':
'Create and track tasks — your own to-dos plus the boards your agents build across conversations.',
'memory.tab.subconscious': 'اللاوعي',
'memory.tab.dreams': 'الأحلام',
'memory.tab.calls': 'المكالمات',
+36
View File
@@ -420,6 +420,42 @@ const ar2: TranslationMap = {
'تكوين إعدادات فرز الذكاء الاصطناعي لمشغلات التكامل Composio',
'mic.deviceSelector': 'جهاز الميكروفون',
'mic.tapToSendCountdown': 'انقر للإرسال ({seconds} ث)',
'settings.agents.title': 'Agents',
'settings.agents.subtitle':
'Manage the agents available for delegation — built-in defaults and your own custom agents.',
'settings.agents.menuDesc': 'Manage built-in and custom agents',
'settings.agents.newAgent': 'New agent',
'settings.agents.loadError': "Couldn't load agents",
'settings.agents.empty': 'No agents yet',
'settings.agents.sourceDefault': 'Built-in',
'settings.agents.sourceCustom': 'Custom',
'settings.agents.enable': 'Enable agent',
'settings.agents.disable': 'Disable agent',
'settings.agents.edit': 'Edit',
'settings.agents.delete': 'Delete',
'settings.agents.reset': 'Reset to default',
'settings.agents.modelLabel': 'Model',
'settings.agents.toolsLabel': 'Tools',
'settings.agents.toolsAll': 'All tools',
'settings.agents.toolsCount': '{count} tools',
'settings.agents.actionFailed': "Couldn't update the agent",
'settings.agents.orchestratorLocked': 'The orchestrator is always enabled.',
'settings.agents.editor.createTitle': 'New agent',
'settings.agents.editor.editTitle': 'Edit agent',
'settings.agents.editor.id': 'ID',
'settings.agents.editor.idHint': 'Lowercase letters, numbers, _ and - only.',
'settings.agents.editor.name': 'Name',
'settings.agents.editor.description': 'Description',
'settings.agents.editor.model': 'Model (optional)',
'settings.agents.editor.modelPlaceholder': 'e.g. inherit, hint:fast, or a model id',
'settings.agents.editor.systemPrompt': 'System prompt (optional)',
'settings.agents.editor.tools': 'Allowed tools',
'settings.agents.editor.toolsHint': 'One tool name per line. Use * for all tools.',
'settings.agents.editor.defaultsNote':
'Editing a built-in agent saves an override you can reset later.',
'settings.agents.editor.save': 'Save',
'settings.agents.editor.create': 'Create agent',
'settings.agents.editor.saving': 'Saving…',
};
export default ar2;
+18
View File
@@ -151,6 +151,23 @@ const ar4: TranslationMap = {
'intelligence.tasks.live': 'مباشر',
'intelligence.tasks.loadingBoards': 'جارٍ تحميل لوحات المهام…',
'intelligence.tasks.threadPrefix': 'المحادثة {thread}',
'intelligence.tasks.subtitle': 'Your tasks and agent task boards across the workspace.',
'intelligence.tasks.newTask': 'New task',
'intelligence.tasks.personalBoardTitle': 'Agent Tasks',
'intelligence.tasks.personalEmpty': 'No personal tasks yet',
'intelligence.tasks.composer.title': 'New task',
'intelligence.tasks.composer.titleLabel': 'Title',
'intelligence.tasks.composer.titlePlaceholder': 'What needs to be done?',
'intelligence.tasks.composer.statusLabel': 'Status',
'intelligence.tasks.composer.attachLabel': 'Attach to conversation',
'intelligence.tasks.composer.attachNone': 'Personal (no conversation)',
'intelligence.tasks.composer.objectiveLabel': 'Objective',
'intelligence.tasks.composer.objectivePlaceholder': 'Optional — the desired outcome',
'intelligence.tasks.composer.notesLabel': 'Notes',
'intelligence.tasks.composer.notesPlaceholder': 'Optional notes',
'intelligence.tasks.composer.create': 'Create task',
'intelligence.tasks.composer.creating': 'Creating…',
'intelligence.tasks.composer.createFailed': "Couldn't create the task",
'notifications.card.dismiss': 'إغلاق الإشعار',
'notifications.card.importanceTitle': 'الأهمية: {pct}%',
'notifications.center.empty': 'لا توجد إشعارات بعد',
@@ -449,6 +466,7 @@ const ar4: TranslationMap = {
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.deleteCard': 'Delete',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
+2
View File
@@ -552,6 +552,8 @@ const ar5: TranslationMap = {
'skills.uninstall.confirmTitle': 'إلغاء تثبيت {name}؟',
'conversations.taskKanban.blocked': 'محظور',
'conversations.taskKanban.done': 'مكتمل',
'conversations.taskKanban.pending': 'Pending',
'conversations.taskKanban.working': 'Working',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
+3
View File
@@ -177,6 +177,9 @@ const bn1: TranslationMap = {
'memory.empty':
'এখনো কোনো মেমোরি নেই। আপনি যত ইন্টারঅ্যাক্ট করবেন, মেমোরি স্বয়ংক্রিয়ভাবে তৈরি হবে।',
'memory.tab.memory': 'মেমোরি',
'memory.tab.tasks': 'Agent Tasks',
'memory.tab.tasksDescription':
'Create and track tasks — your own to-dos plus the boards your agents build across conversations.',
'memory.tab.subconscious': 'সাবকনশাস',
'memory.tab.dreams': 'স্বপ্ন',
'memory.tab.calls': 'কল',
+36
View File
@@ -433,6 +433,42 @@ const bn2: TranslationMap = {
'Composio ইন্টিগ্রেশন ট্রিগারের জন্য AI ট্রাইজ সেটিংস কনফিগার করুন',
'mic.deviceSelector': 'মাইক্রোফোন ডিভাইস',
'mic.tapToSendCountdown': 'পাঠাতে ট্যাপ করুন ({seconds}স)',
'settings.agents.title': 'Agents',
'settings.agents.subtitle':
'Manage the agents available for delegation — built-in defaults and your own custom agents.',
'settings.agents.menuDesc': 'Manage built-in and custom agents',
'settings.agents.newAgent': 'New agent',
'settings.agents.loadError': "Couldn't load agents",
'settings.agents.empty': 'No agents yet',
'settings.agents.sourceDefault': 'Built-in',
'settings.agents.sourceCustom': 'Custom',
'settings.agents.enable': 'Enable agent',
'settings.agents.disable': 'Disable agent',
'settings.agents.edit': 'Edit',
'settings.agents.delete': 'Delete',
'settings.agents.reset': 'Reset to default',
'settings.agents.modelLabel': 'Model',
'settings.agents.toolsLabel': 'Tools',
'settings.agents.toolsAll': 'All tools',
'settings.agents.toolsCount': '{count} tools',
'settings.agents.actionFailed': "Couldn't update the agent",
'settings.agents.orchestratorLocked': 'The orchestrator is always enabled.',
'settings.agents.editor.createTitle': 'New agent',
'settings.agents.editor.editTitle': 'Edit agent',
'settings.agents.editor.id': 'ID',
'settings.agents.editor.idHint': 'Lowercase letters, numbers, _ and - only.',
'settings.agents.editor.name': 'Name',
'settings.agents.editor.description': 'Description',
'settings.agents.editor.model': 'Model (optional)',
'settings.agents.editor.modelPlaceholder': 'e.g. inherit, hint:fast, or a model id',
'settings.agents.editor.systemPrompt': 'System prompt (optional)',
'settings.agents.editor.tools': 'Allowed tools',
'settings.agents.editor.toolsHint': 'One tool name per line. Use * for all tools.',
'settings.agents.editor.defaultsNote':
'Editing a built-in agent saves an override you can reset later.',
'settings.agents.editor.save': 'Save',
'settings.agents.editor.create': 'Create agent',
'settings.agents.editor.saving': 'Saving…',
};
export default bn2;
+18
View File
@@ -152,6 +152,23 @@ const bn4: TranslationMap = {
'intelligence.tasks.live': 'লাইভ',
'intelligence.tasks.loadingBoards': 'টাস্ক বোর্ড লোড হচ্ছে…',
'intelligence.tasks.threadPrefix': 'থ্রেড {thread}',
'intelligence.tasks.subtitle': 'Your tasks and agent task boards across the workspace.',
'intelligence.tasks.newTask': 'New task',
'intelligence.tasks.personalBoardTitle': 'Agent Tasks',
'intelligence.tasks.personalEmpty': 'No personal tasks yet',
'intelligence.tasks.composer.title': 'New task',
'intelligence.tasks.composer.titleLabel': 'Title',
'intelligence.tasks.composer.titlePlaceholder': 'What needs to be done?',
'intelligence.tasks.composer.statusLabel': 'Status',
'intelligence.tasks.composer.attachLabel': 'Attach to conversation',
'intelligence.tasks.composer.attachNone': 'Personal (no conversation)',
'intelligence.tasks.composer.objectiveLabel': 'Objective',
'intelligence.tasks.composer.objectivePlaceholder': 'Optional — the desired outcome',
'intelligence.tasks.composer.notesLabel': 'Notes',
'intelligence.tasks.composer.notesPlaceholder': 'Optional notes',
'intelligence.tasks.composer.create': 'Create task',
'intelligence.tasks.composer.creating': 'Creating…',
'intelligence.tasks.composer.createFailed': "Couldn't create the task",
'notifications.card.dismiss': 'বিজ্ঞপ্তি বাদ দিন',
'notifications.card.importanceTitle': 'গুরুত্ব: {pct}%',
'notifications.center.empty': 'এখনো কোনো বিজ্ঞপ্তি নেই',
@@ -452,6 +469,7 @@ const bn4: TranslationMap = {
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.deleteCard': 'Delete',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
+2
View File
@@ -559,6 +559,8 @@ const bn5: TranslationMap = {
'skills.uninstall.confirmTitle': '{name} আনইনস্টল করবেন?',
'conversations.taskKanban.blocked': 'ব্লকড',
'conversations.taskKanban.done': 'সম্পন্ন',
'conversations.taskKanban.pending': 'Pending',
'conversations.taskKanban.working': 'Working',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
+3
View File
@@ -216,6 +216,9 @@ const de1: TranslationMap = {
'memory.empty':
'Noch keine Erinnerungen. Erinnerungen werden automatisch erstellt, während du interagierst.',
'memory.tab.memory': 'Erinnerung',
'memory.tab.tasks': 'Agent Tasks',
'memory.tab.tasksDescription':
'Create and track tasks — your own to-dos plus the boards your agents build across conversations.',
'memory.tab.subconscious': 'Unterbewusstsein',
'memory.tab.dreams': 'Träume',
'memory.tab.calls': 'Anrufe',
+36
View File
@@ -444,6 +444,42 @@ const de2: TranslationMap = {
'Konfiguriere KI-Triage-Einstellungen für Composio-Integrationsauslöser',
'mic.deviceSelector': 'Mikrofongerät',
'mic.tapToSendCountdown': 'Zum Senden tippen ({seconds}s)',
'settings.agents.title': 'Agents',
'settings.agents.subtitle':
'Manage the agents available for delegation — built-in defaults and your own custom agents.',
'settings.agents.menuDesc': 'Manage built-in and custom agents',
'settings.agents.newAgent': 'New agent',
'settings.agents.loadError': "Couldn't load agents",
'settings.agents.empty': 'No agents yet',
'settings.agents.sourceDefault': 'Built-in',
'settings.agents.sourceCustom': 'Custom',
'settings.agents.enable': 'Enable agent',
'settings.agents.disable': 'Disable agent',
'settings.agents.edit': 'Edit',
'settings.agents.delete': 'Delete',
'settings.agents.reset': 'Reset to default',
'settings.agents.modelLabel': 'Model',
'settings.agents.toolsLabel': 'Tools',
'settings.agents.toolsAll': 'All tools',
'settings.agents.toolsCount': '{count} tools',
'settings.agents.actionFailed': "Couldn't update the agent",
'settings.agents.orchestratorLocked': 'The orchestrator is always enabled.',
'settings.agents.editor.createTitle': 'New agent',
'settings.agents.editor.editTitle': 'Edit agent',
'settings.agents.editor.id': 'ID',
'settings.agents.editor.idHint': 'Lowercase letters, numbers, _ and - only.',
'settings.agents.editor.name': 'Name',
'settings.agents.editor.description': 'Description',
'settings.agents.editor.model': 'Model (optional)',
'settings.agents.editor.modelPlaceholder': 'e.g. inherit, hint:fast, or a model id',
'settings.agents.editor.systemPrompt': 'System prompt (optional)',
'settings.agents.editor.tools': 'Allowed tools',
'settings.agents.editor.toolsHint': 'One tool name per line. Use * for all tools.',
'settings.agents.editor.defaultsNote':
'Editing a built-in agent saves an override you can reset later.',
'settings.agents.editor.save': 'Save',
'settings.agents.editor.create': 'Create agent',
'settings.agents.editor.saving': 'Saving…',
};
export default de2;
+18
View File
@@ -152,6 +152,23 @@ const de4: TranslationMap = {
'intelligence.tasks.live': 'leben',
'intelligence.tasks.loadingBoards': 'Taskboards werden geladen…',
'intelligence.tasks.threadPrefix': 'Thread {thread}',
'intelligence.tasks.subtitle': 'Your tasks and agent task boards across the workspace.',
'intelligence.tasks.newTask': 'New task',
'intelligence.tasks.personalBoardTitle': 'Agent Tasks',
'intelligence.tasks.personalEmpty': 'No personal tasks yet',
'intelligence.tasks.composer.title': 'New task',
'intelligence.tasks.composer.titleLabel': 'Title',
'intelligence.tasks.composer.titlePlaceholder': 'What needs to be done?',
'intelligence.tasks.composer.statusLabel': 'Status',
'intelligence.tasks.composer.attachLabel': 'Attach to conversation',
'intelligence.tasks.composer.attachNone': 'Personal (no conversation)',
'intelligence.tasks.composer.objectiveLabel': 'Objective',
'intelligence.tasks.composer.objectivePlaceholder': 'Optional — the desired outcome',
'intelligence.tasks.composer.notesLabel': 'Notes',
'intelligence.tasks.composer.notesPlaceholder': 'Optional notes',
'intelligence.tasks.composer.create': 'Create task',
'intelligence.tasks.composer.creating': 'Creating…',
'intelligence.tasks.composer.createFailed': "Couldn't create the task",
'notifications.card.dismiss': 'Benachrichtigung verwerfen',
'notifications.card.importanceTitle': 'Wichtigkeit: {pct}%',
'notifications.center.empty': 'Noch keine Benachrichtigungen',
@@ -458,6 +475,7 @@ const de4: TranslationMap = {
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.deleteCard': 'Delete',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
+2
View File
@@ -582,6 +582,8 @@ const de5: TranslationMap = {
'skills.uninstall.confirmTitle': '{name} deinstallieren?',
'conversations.taskKanban.blocked': 'Blockiert',
'conversations.taskKanban.done': 'Fertig',
'conversations.taskKanban.pending': 'Pending',
'conversations.taskKanban.working': 'Working',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
+3
View File
@@ -484,6 +484,9 @@ const en1: TranslationMap = {
'memory.noResults': 'No memories found',
'memory.empty': 'No memories yet. Memories are created automatically as you interact.',
'memory.tab.memory': 'Memory',
'memory.tab.tasks': 'Agent Tasks',
'memory.tab.tasksDescription':
'Create and track tasks — your own to-dos plus the boards your agents build across conversations.',
'memory.tab.subconscious': 'Subconscious',
'memory.tab.dreams': 'Dreams',
'memory.tab.calls': 'Calls',
+36
View File
@@ -429,6 +429,42 @@ const en2: TranslationMap = {
'devOptions.menuComposioTriggers': 'Integration Triggers',
'devOptions.menuComposioTriggersDesc':
'Configure AI triage settings for Composio integration triggers',
'settings.agents.title': 'Agents',
'settings.agents.subtitle':
'Manage the agents available for delegation — built-in defaults and your own custom agents.',
'settings.agents.menuDesc': 'Manage built-in and custom agents',
'settings.agents.newAgent': 'New agent',
'settings.agents.loadError': "Couldn't load agents",
'settings.agents.empty': 'No agents yet',
'settings.agents.sourceDefault': 'Built-in',
'settings.agents.sourceCustom': 'Custom',
'settings.agents.enable': 'Enable agent',
'settings.agents.disable': 'Disable agent',
'settings.agents.edit': 'Edit',
'settings.agents.delete': 'Delete',
'settings.agents.reset': 'Reset to default',
'settings.agents.modelLabel': 'Model',
'settings.agents.toolsLabel': 'Tools',
'settings.agents.toolsAll': 'All tools',
'settings.agents.toolsCount': '{count} tools',
'settings.agents.actionFailed': "Couldn't update the agent",
'settings.agents.orchestratorLocked': 'The orchestrator is always enabled.',
'settings.agents.editor.createTitle': 'New agent',
'settings.agents.editor.editTitle': 'Edit agent',
'settings.agents.editor.id': 'ID',
'settings.agents.editor.idHint': 'Lowercase letters, numbers, _ and - only.',
'settings.agents.editor.name': 'Name',
'settings.agents.editor.description': 'Description',
'settings.agents.editor.model': 'Model (optional)',
'settings.agents.editor.modelPlaceholder': 'e.g. inherit, hint:fast, or a model id',
'settings.agents.editor.systemPrompt': 'System prompt (optional)',
'settings.agents.editor.tools': 'Allowed tools',
'settings.agents.editor.toolsHint': 'One tool name per line. Use * for all tools.',
'settings.agents.editor.defaultsNote':
'Editing a built-in agent saves an override you can reset later.',
'settings.agents.editor.save': 'Save',
'settings.agents.editor.create': 'Create agent',
'settings.agents.editor.saving': 'Saving…',
};
export default en2;
+18
View File
@@ -97,6 +97,7 @@ const en4: TranslationMap = {
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.deleteCard': 'Delete',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
'conversations.toolTimeline.turn': 'turn',
'conversations.toolTimeline.workerThread': 'worker thread',
@@ -182,6 +183,23 @@ const en4: TranslationMap = {
'intelligence.tasks.live': 'live',
'intelligence.tasks.loadingBoards': 'Loading task boards…',
'intelligence.tasks.threadPrefix': 'Thread {thread}',
'intelligence.tasks.subtitle': 'Your tasks and agent task boards across the workspace.',
'intelligence.tasks.newTask': 'New task',
'intelligence.tasks.personalBoardTitle': 'Agent Tasks',
'intelligence.tasks.personalEmpty': 'No personal tasks yet',
'intelligence.tasks.composer.title': 'New task',
'intelligence.tasks.composer.titleLabel': 'Title',
'intelligence.tasks.composer.titlePlaceholder': 'What needs to be done?',
'intelligence.tasks.composer.statusLabel': 'Status',
'intelligence.tasks.composer.attachLabel': 'Attach to conversation',
'intelligence.tasks.composer.attachNone': 'Personal (no conversation)',
'intelligence.tasks.composer.objectiveLabel': 'Objective',
'intelligence.tasks.composer.objectivePlaceholder': 'Optional — the desired outcome',
'intelligence.tasks.composer.notesLabel': 'Notes',
'intelligence.tasks.composer.notesPlaceholder': 'Optional notes',
'intelligence.tasks.composer.create': 'Create task',
'intelligence.tasks.composer.creating': 'Creating…',
'intelligence.tasks.composer.createFailed': "Couldn't create the task",
'notifications.card.dismiss': 'Dismiss notification',
'notifications.card.importanceTitle': 'Importance: {pct}%',
'notifications.center.empty': 'No notifications yet',
+2
View File
@@ -604,6 +604,8 @@ const en5: TranslationMap = {
'skills.uninstall.confirmTitle': 'Uninstall {name}?',
'conversations.taskKanban.blocked': 'Blocked',
'conversations.taskKanban.done': 'Done',
'conversations.taskKanban.pending': 'Pending',
'conversations.taskKanban.working': 'Working',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
+3
View File
@@ -183,6 +183,9 @@ const es1: TranslationMap = {
'memory.noResults': 'No se encontraron recuerdos',
'memory.empty': 'Sin recuerdos aún. Los recuerdos se crean automáticamente mientras interactúas.',
'memory.tab.memory': 'Memoria',
'memory.tab.tasks': 'Agent Tasks',
'memory.tab.tasksDescription':
'Create and track tasks — your own to-dos plus the boards your agents build across conversations.',
'memory.tab.subconscious': 'Subconsciente',
'memory.tab.dreams': 'Sueños',
'memory.tab.calls': 'Llamadas',
+36
View File
@@ -443,6 +443,42 @@ const es2: TranslationMap = {
'Configurar los ajustes de clasificación de IA para los activadores de integración Composio',
'mic.deviceSelector': 'Dispositivo de micrófono',
'mic.tapToSendCountdown': 'Toca para enviar ({seconds}s)',
'settings.agents.title': 'Agents',
'settings.agents.subtitle':
'Manage the agents available for delegation — built-in defaults and your own custom agents.',
'settings.agents.menuDesc': 'Manage built-in and custom agents',
'settings.agents.newAgent': 'New agent',
'settings.agents.loadError': "Couldn't load agents",
'settings.agents.empty': 'No agents yet',
'settings.agents.sourceDefault': 'Built-in',
'settings.agents.sourceCustom': 'Custom',
'settings.agents.enable': 'Enable agent',
'settings.agents.disable': 'Disable agent',
'settings.agents.edit': 'Edit',
'settings.agents.delete': 'Delete',
'settings.agents.reset': 'Reset to default',
'settings.agents.modelLabel': 'Model',
'settings.agents.toolsLabel': 'Tools',
'settings.agents.toolsAll': 'All tools',
'settings.agents.toolsCount': '{count} tools',
'settings.agents.actionFailed': "Couldn't update the agent",
'settings.agents.orchestratorLocked': 'The orchestrator is always enabled.',
'settings.agents.editor.createTitle': 'New agent',
'settings.agents.editor.editTitle': 'Edit agent',
'settings.agents.editor.id': 'ID',
'settings.agents.editor.idHint': 'Lowercase letters, numbers, _ and - only.',
'settings.agents.editor.name': 'Name',
'settings.agents.editor.description': 'Description',
'settings.agents.editor.model': 'Model (optional)',
'settings.agents.editor.modelPlaceholder': 'e.g. inherit, hint:fast, or a model id',
'settings.agents.editor.systemPrompt': 'System prompt (optional)',
'settings.agents.editor.tools': 'Allowed tools',
'settings.agents.editor.toolsHint': 'One tool name per line. Use * for all tools.',
'settings.agents.editor.defaultsNote':
'Editing a built-in agent saves an override you can reset later.',
'settings.agents.editor.save': 'Save',
'settings.agents.editor.create': 'Create agent',
'settings.agents.editor.saving': 'Saving…',
};
export default es2;
+18
View File
@@ -152,6 +152,23 @@ const es4: TranslationMap = {
'intelligence.tasks.live': 'en vivo',
'intelligence.tasks.loadingBoards': 'Cargando tableros de tareas…',
'intelligence.tasks.threadPrefix': 'Hilo {thread}',
'intelligence.tasks.subtitle': 'Your tasks and agent task boards across the workspace.',
'intelligence.tasks.newTask': 'New task',
'intelligence.tasks.personalBoardTitle': 'Agent Tasks',
'intelligence.tasks.personalEmpty': 'No personal tasks yet',
'intelligence.tasks.composer.title': 'New task',
'intelligence.tasks.composer.titleLabel': 'Title',
'intelligence.tasks.composer.titlePlaceholder': 'What needs to be done?',
'intelligence.tasks.composer.statusLabel': 'Status',
'intelligence.tasks.composer.attachLabel': 'Attach to conversation',
'intelligence.tasks.composer.attachNone': 'Personal (no conversation)',
'intelligence.tasks.composer.objectiveLabel': 'Objective',
'intelligence.tasks.composer.objectivePlaceholder': 'Optional — the desired outcome',
'intelligence.tasks.composer.notesLabel': 'Notes',
'intelligence.tasks.composer.notesPlaceholder': 'Optional notes',
'intelligence.tasks.composer.create': 'Create task',
'intelligence.tasks.composer.creating': 'Creating…',
'intelligence.tasks.composer.createFailed': "Couldn't create the task",
'notifications.card.dismiss': 'Descartar notificación',
'notifications.card.importanceTitle': 'Importancia: {pct}%',
'notifications.center.empty': 'Sin notificaciones aún',
@@ -456,6 +473,7 @@ const es4: TranslationMap = {
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.deleteCard': 'Delete',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
+2
View File
@@ -564,6 +564,8 @@ const es5: TranslationMap = {
'skills.uninstall.confirmTitle': '¿Desinstalar {name}?',
'conversations.taskKanban.blocked': 'Bloqueado',
'conversations.taskKanban.done': 'Completado',
'conversations.taskKanban.pending': 'Pending',
'conversations.taskKanban.working': 'Working',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
+3
View File
@@ -183,6 +183,9 @@ const fr1: TranslationMap = {
'memory.empty':
"Aucun souvenir pour l'instant. Les souvenirs sont créés automatiquement au fil de tes interactions.",
'memory.tab.memory': 'Mémoire',
'memory.tab.tasks': 'Agent Tasks',
'memory.tab.tasksDescription':
'Create and track tasks — your own to-dos plus the boards your agents build across conversations.',
'memory.tab.subconscious': 'Subconscient',
'memory.tab.dreams': 'Rêves',
'memory.tab.calls': 'Appels',
+36
View File
@@ -445,6 +445,42 @@ const fr2: TranslationMap = {
"Configurez les paramètres de triage IA pour les déclencheurs d'intégration Composio",
'mic.deviceSelector': 'Dispositif de microphone',
'mic.tapToSendCountdown': 'Appuie pour envoyer ({seconds}s)',
'settings.agents.title': 'Agents',
'settings.agents.subtitle':
'Manage the agents available for delegation — built-in defaults and your own custom agents.',
'settings.agents.menuDesc': 'Manage built-in and custom agents',
'settings.agents.newAgent': 'New agent',
'settings.agents.loadError': "Couldn't load agents",
'settings.agents.empty': 'No agents yet',
'settings.agents.sourceDefault': 'Built-in',
'settings.agents.sourceCustom': 'Custom',
'settings.agents.enable': 'Enable agent',
'settings.agents.disable': 'Disable agent',
'settings.agents.edit': 'Edit',
'settings.agents.delete': 'Delete',
'settings.agents.reset': 'Reset to default',
'settings.agents.modelLabel': 'Model',
'settings.agents.toolsLabel': 'Tools',
'settings.agents.toolsAll': 'All tools',
'settings.agents.toolsCount': '{count} tools',
'settings.agents.actionFailed': "Couldn't update the agent",
'settings.agents.orchestratorLocked': 'The orchestrator is always enabled.',
'settings.agents.editor.createTitle': 'New agent',
'settings.agents.editor.editTitle': 'Edit agent',
'settings.agents.editor.id': 'ID',
'settings.agents.editor.idHint': 'Lowercase letters, numbers, _ and - only.',
'settings.agents.editor.name': 'Name',
'settings.agents.editor.description': 'Description',
'settings.agents.editor.model': 'Model (optional)',
'settings.agents.editor.modelPlaceholder': 'e.g. inherit, hint:fast, or a model id',
'settings.agents.editor.systemPrompt': 'System prompt (optional)',
'settings.agents.editor.tools': 'Allowed tools',
'settings.agents.editor.toolsHint': 'One tool name per line. Use * for all tools.',
'settings.agents.editor.defaultsNote':
'Editing a built-in agent saves an override you can reset later.',
'settings.agents.editor.save': 'Save',
'settings.agents.editor.create': 'Create agent',
'settings.agents.editor.saving': 'Saving…',
};
export default fr2;
+18
View File
@@ -152,6 +152,23 @@ const fr4: TranslationMap = {
'intelligence.tasks.live': 'en direct',
'intelligence.tasks.loadingBoards': 'Chargement des tableaux de tâches…',
'intelligence.tasks.threadPrefix': 'Fil {thread}',
'intelligence.tasks.subtitle': 'Your tasks and agent task boards across the workspace.',
'intelligence.tasks.newTask': 'New task',
'intelligence.tasks.personalBoardTitle': 'Agent Tasks',
'intelligence.tasks.personalEmpty': 'No personal tasks yet',
'intelligence.tasks.composer.title': 'New task',
'intelligence.tasks.composer.titleLabel': 'Title',
'intelligence.tasks.composer.titlePlaceholder': 'What needs to be done?',
'intelligence.tasks.composer.statusLabel': 'Status',
'intelligence.tasks.composer.attachLabel': 'Attach to conversation',
'intelligence.tasks.composer.attachNone': 'Personal (no conversation)',
'intelligence.tasks.composer.objectiveLabel': 'Objective',
'intelligence.tasks.composer.objectivePlaceholder': 'Optional — the desired outcome',
'intelligence.tasks.composer.notesLabel': 'Notes',
'intelligence.tasks.composer.notesPlaceholder': 'Optional notes',
'intelligence.tasks.composer.create': 'Create task',
'intelligence.tasks.composer.creating': 'Creating…',
'intelligence.tasks.composer.createFailed': "Couldn't create the task",
'notifications.card.dismiss': 'Ignorer la notification',
'notifications.card.importanceTitle': 'Importance : {pct} %',
'notifications.center.empty': "Aucune notification pour l'instant",
@@ -455,6 +472,7 @@ const fr4: TranslationMap = {
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.deleteCard': 'Delete',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
+2
View File
@@ -568,6 +568,8 @@ const fr5: TranslationMap = {
'skills.uninstall.confirmTitle': 'Désinstaller {name} ?',
'conversations.taskKanban.blocked': 'Bloqué',
'conversations.taskKanban.done': 'Terminé',
'conversations.taskKanban.pending': 'Pending',
'conversations.taskKanban.working': 'Working',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
+3
View File
@@ -175,6 +175,9 @@ const hi1: TranslationMap = {
'memory.noResults': 'कोई मेमोरी नहीं मिली',
'memory.empty': 'अभी कोई मेमोरी नहीं है। बातचीत के दौरान मेमोरी अपने आप बनती है।',
'memory.tab.memory': 'मेमोरी',
'memory.tab.tasks': 'Agent Tasks',
'memory.tab.tasksDescription':
'Create and track tasks — your own to-dos plus the boards your agents build across conversations.',
'memory.tab.subconscious': 'सबकॉन्शस',
'memory.tab.dreams': 'ड्रीम्स',
'memory.tab.calls': 'कॉल्स',
+36
View File
@@ -431,6 +431,42 @@ const hi2: TranslationMap = {
'Composio एकीकरण ट्रिगर के लिए AI ट्राइएज सेटिंग्स कॉन्फ़िगर करें',
'mic.deviceSelector': 'माइक्रोफोन डिवाइस',
'mic.tapToSendCountdown': 'भेजने के लिए टैप करें ({seconds}स)',
'settings.agents.title': 'Agents',
'settings.agents.subtitle':
'Manage the agents available for delegation — built-in defaults and your own custom agents.',
'settings.agents.menuDesc': 'Manage built-in and custom agents',
'settings.agents.newAgent': 'New agent',
'settings.agents.loadError': "Couldn't load agents",
'settings.agents.empty': 'No agents yet',
'settings.agents.sourceDefault': 'Built-in',
'settings.agents.sourceCustom': 'Custom',
'settings.agents.enable': 'Enable agent',
'settings.agents.disable': 'Disable agent',
'settings.agents.edit': 'Edit',
'settings.agents.delete': 'Delete',
'settings.agents.reset': 'Reset to default',
'settings.agents.modelLabel': 'Model',
'settings.agents.toolsLabel': 'Tools',
'settings.agents.toolsAll': 'All tools',
'settings.agents.toolsCount': '{count} tools',
'settings.agents.actionFailed': "Couldn't update the agent",
'settings.agents.orchestratorLocked': 'The orchestrator is always enabled.',
'settings.agents.editor.createTitle': 'New agent',
'settings.agents.editor.editTitle': 'Edit agent',
'settings.agents.editor.id': 'ID',
'settings.agents.editor.idHint': 'Lowercase letters, numbers, _ and - only.',
'settings.agents.editor.name': 'Name',
'settings.agents.editor.description': 'Description',
'settings.agents.editor.model': 'Model (optional)',
'settings.agents.editor.modelPlaceholder': 'e.g. inherit, hint:fast, or a model id',
'settings.agents.editor.systemPrompt': 'System prompt (optional)',
'settings.agents.editor.tools': 'Allowed tools',
'settings.agents.editor.toolsHint': 'One tool name per line. Use * for all tools.',
'settings.agents.editor.defaultsNote':
'Editing a built-in agent saves an override you can reset later.',
'settings.agents.editor.save': 'Save',
'settings.agents.editor.create': 'Create agent',
'settings.agents.editor.saving': 'Saving…',
};
export default hi2;
+18
View File
@@ -152,6 +152,23 @@ const hi4: TranslationMap = {
'intelligence.tasks.live': 'लाइव',
'intelligence.tasks.loadingBoards': 'टास्क बोर्ड लोड हो रहे हैं…',
'intelligence.tasks.threadPrefix': 'थ्रेड {thread}',
'intelligence.tasks.subtitle': 'Your tasks and agent task boards across the workspace.',
'intelligence.tasks.newTask': 'New task',
'intelligence.tasks.personalBoardTitle': 'Agent Tasks',
'intelligence.tasks.personalEmpty': 'No personal tasks yet',
'intelligence.tasks.composer.title': 'New task',
'intelligence.tasks.composer.titleLabel': 'Title',
'intelligence.tasks.composer.titlePlaceholder': 'What needs to be done?',
'intelligence.tasks.composer.statusLabel': 'Status',
'intelligence.tasks.composer.attachLabel': 'Attach to conversation',
'intelligence.tasks.composer.attachNone': 'Personal (no conversation)',
'intelligence.tasks.composer.objectiveLabel': 'Objective',
'intelligence.tasks.composer.objectivePlaceholder': 'Optional — the desired outcome',
'intelligence.tasks.composer.notesLabel': 'Notes',
'intelligence.tasks.composer.notesPlaceholder': 'Optional notes',
'intelligence.tasks.composer.create': 'Create task',
'intelligence.tasks.composer.creating': 'Creating…',
'intelligence.tasks.composer.createFailed': "Couldn't create the task",
'notifications.card.dismiss': 'नोटिफिकेशन हटाएं',
'notifications.card.importanceTitle': 'महत्व: {pct}%',
'notifications.center.empty': 'अभी कोई नोटिफिकेशन नहीं',
@@ -453,6 +470,7 @@ const hi4: TranslationMap = {
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.deleteCard': 'Delete',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
+2
View File
@@ -561,6 +561,8 @@ const hi5: TranslationMap = {
'skills.uninstall.confirmTitle': '{name} अनइंस्टॉल करें?',
'conversations.taskKanban.blocked': 'अवरुद्ध',
'conversations.taskKanban.done': 'पूर्ण',
'conversations.taskKanban.pending': 'Pending',
'conversations.taskKanban.working': 'Working',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
+3
View File
@@ -176,6 +176,9 @@ const id1: TranslationMap = {
'memory.noResults': 'Memori tidak ditemukan',
'memory.empty': 'Belum ada memori. Memori dibuat otomatis saat Anda berinteraksi.',
'memory.tab.memory': 'Memori',
'memory.tab.tasks': 'Agent Tasks',
'memory.tab.tasksDescription':
'Create and track tasks — your own to-dos plus the boards your agents build across conversations.',
'memory.tab.subconscious': 'Bawah sadar',
'memory.tab.dreams': 'Mimpi',
'memory.tab.calls': 'Panggilan',
+36
View File
@@ -431,6 +431,42 @@ const id2: TranslationMap = {
'Konfigurasikan pengaturan triase AI untuk pemicu integrasi Composio',
'mic.deviceSelector': 'Perangkat mikrofon',
'mic.tapToSendCountdown': 'Ketuk untuk mengirim ({seconds}d)',
'settings.agents.title': 'Agents',
'settings.agents.subtitle':
'Manage the agents available for delegation — built-in defaults and your own custom agents.',
'settings.agents.menuDesc': 'Manage built-in and custom agents',
'settings.agents.newAgent': 'New agent',
'settings.agents.loadError': "Couldn't load agents",
'settings.agents.empty': 'No agents yet',
'settings.agents.sourceDefault': 'Built-in',
'settings.agents.sourceCustom': 'Custom',
'settings.agents.enable': 'Enable agent',
'settings.agents.disable': 'Disable agent',
'settings.agents.edit': 'Edit',
'settings.agents.delete': 'Delete',
'settings.agents.reset': 'Reset to default',
'settings.agents.modelLabel': 'Model',
'settings.agents.toolsLabel': 'Tools',
'settings.agents.toolsAll': 'All tools',
'settings.agents.toolsCount': '{count} tools',
'settings.agents.actionFailed': "Couldn't update the agent",
'settings.agents.orchestratorLocked': 'The orchestrator is always enabled.',
'settings.agents.editor.createTitle': 'New agent',
'settings.agents.editor.editTitle': 'Edit agent',
'settings.agents.editor.id': 'ID',
'settings.agents.editor.idHint': 'Lowercase letters, numbers, _ and - only.',
'settings.agents.editor.name': 'Name',
'settings.agents.editor.description': 'Description',
'settings.agents.editor.model': 'Model (optional)',
'settings.agents.editor.modelPlaceholder': 'e.g. inherit, hint:fast, or a model id',
'settings.agents.editor.systemPrompt': 'System prompt (optional)',
'settings.agents.editor.tools': 'Allowed tools',
'settings.agents.editor.toolsHint': 'One tool name per line. Use * for all tools.',
'settings.agents.editor.defaultsNote':
'Editing a built-in agent saves an override you can reset later.',
'settings.agents.editor.save': 'Save',
'settings.agents.editor.create': 'Create agent',
'settings.agents.editor.saving': 'Saving…',
};
export default id2;
+18
View File
@@ -152,6 +152,23 @@ const id4: TranslationMap = {
'intelligence.tasks.live': 'langsung',
'intelligence.tasks.loadingBoards': 'Memuat papan tugas...',
'intelligence.tasks.threadPrefix': 'Utas {thread}',
'intelligence.tasks.subtitle': 'Your tasks and agent task boards across the workspace.',
'intelligence.tasks.newTask': 'New task',
'intelligence.tasks.personalBoardTitle': 'Agent Tasks',
'intelligence.tasks.personalEmpty': 'No personal tasks yet',
'intelligence.tasks.composer.title': 'New task',
'intelligence.tasks.composer.titleLabel': 'Title',
'intelligence.tasks.composer.titlePlaceholder': 'What needs to be done?',
'intelligence.tasks.composer.statusLabel': 'Status',
'intelligence.tasks.composer.attachLabel': 'Attach to conversation',
'intelligence.tasks.composer.attachNone': 'Personal (no conversation)',
'intelligence.tasks.composer.objectiveLabel': 'Objective',
'intelligence.tasks.composer.objectivePlaceholder': 'Optional — the desired outcome',
'intelligence.tasks.composer.notesLabel': 'Notes',
'intelligence.tasks.composer.notesPlaceholder': 'Optional notes',
'intelligence.tasks.composer.create': 'Create task',
'intelligence.tasks.composer.creating': 'Creating…',
'intelligence.tasks.composer.createFailed': "Couldn't create the task",
'notifications.card.dismiss': 'Abaikan notifikasi',
'notifications.card.importanceTitle': 'Tingkat penting: {pct}%',
'notifications.center.empty': 'Belum ada notifikasi',
@@ -454,6 +471,7 @@ const id4: TranslationMap = {
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.deleteCard': 'Delete',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
+2
View File
@@ -561,6 +561,8 @@ const id5: TranslationMap = {
'skills.uninstall.confirmTitle': 'Copot {name}?',
'conversations.taskKanban.blocked': 'Terhambat',
'conversations.taskKanban.done': 'Selesai',
'conversations.taskKanban.pending': 'Pending',
'conversations.taskKanban.working': 'Working',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
+3
View File
@@ -181,6 +181,9 @@ const it1: TranslationMap = {
'memory.empty':
'Nessuna memoria ancora. Le memorie vengono create automaticamente mentre interagisci.',
'memory.tab.memory': 'Memoria',
'memory.tab.tasks': 'Agent Tasks',
'memory.tab.tasksDescription':
'Create and track tasks — your own to-dos plus the boards your agents build across conversations.',
'memory.tab.subconscious': 'Subconscio',
'memory.tab.dreams': 'Sogni',
'memory.tab.calls': 'Chiamate',
+36
View File
@@ -438,6 +438,42 @@ const it2: TranslationMap = {
'Configura le impostazioni di triage AI per i trigger di integrazione Composio',
'mic.deviceSelector': 'Dispositivo microfono',
'mic.tapToSendCountdown': 'Tocca per inviare ({seconds}s)',
'settings.agents.title': 'Agents',
'settings.agents.subtitle':
'Manage the agents available for delegation — built-in defaults and your own custom agents.',
'settings.agents.menuDesc': 'Manage built-in and custom agents',
'settings.agents.newAgent': 'New agent',
'settings.agents.loadError': "Couldn't load agents",
'settings.agents.empty': 'No agents yet',
'settings.agents.sourceDefault': 'Built-in',
'settings.agents.sourceCustom': 'Custom',
'settings.agents.enable': 'Enable agent',
'settings.agents.disable': 'Disable agent',
'settings.agents.edit': 'Edit',
'settings.agents.delete': 'Delete',
'settings.agents.reset': 'Reset to default',
'settings.agents.modelLabel': 'Model',
'settings.agents.toolsLabel': 'Tools',
'settings.agents.toolsAll': 'All tools',
'settings.agents.toolsCount': '{count} tools',
'settings.agents.actionFailed': "Couldn't update the agent",
'settings.agents.orchestratorLocked': 'The orchestrator is always enabled.',
'settings.agents.editor.createTitle': 'New agent',
'settings.agents.editor.editTitle': 'Edit agent',
'settings.agents.editor.id': 'ID',
'settings.agents.editor.idHint': 'Lowercase letters, numbers, _ and - only.',
'settings.agents.editor.name': 'Name',
'settings.agents.editor.description': 'Description',
'settings.agents.editor.model': 'Model (optional)',
'settings.agents.editor.modelPlaceholder': 'e.g. inherit, hint:fast, or a model id',
'settings.agents.editor.systemPrompt': 'System prompt (optional)',
'settings.agents.editor.tools': 'Allowed tools',
'settings.agents.editor.toolsHint': 'One tool name per line. Use * for all tools.',
'settings.agents.editor.defaultsNote':
'Editing a built-in agent saves an override you can reset later.',
'settings.agents.editor.save': 'Save',
'settings.agents.editor.create': 'Create agent',
'settings.agents.editor.saving': 'Saving…',
};
export default it2;
+18
View File
@@ -152,6 +152,23 @@ const it4: TranslationMap = {
'intelligence.tasks.live': 'live',
'intelligence.tasks.loadingBoards': 'Caricamento board attività…',
'intelligence.tasks.threadPrefix': 'Discussione {thread}',
'intelligence.tasks.subtitle': 'Your tasks and agent task boards across the workspace.',
'intelligence.tasks.newTask': 'New task',
'intelligence.tasks.personalBoardTitle': 'Agent Tasks',
'intelligence.tasks.personalEmpty': 'No personal tasks yet',
'intelligence.tasks.composer.title': 'New task',
'intelligence.tasks.composer.titleLabel': 'Title',
'intelligence.tasks.composer.titlePlaceholder': 'What needs to be done?',
'intelligence.tasks.composer.statusLabel': 'Status',
'intelligence.tasks.composer.attachLabel': 'Attach to conversation',
'intelligence.tasks.composer.attachNone': 'Personal (no conversation)',
'intelligence.tasks.composer.objectiveLabel': 'Objective',
'intelligence.tasks.composer.objectivePlaceholder': 'Optional — the desired outcome',
'intelligence.tasks.composer.notesLabel': 'Notes',
'intelligence.tasks.composer.notesPlaceholder': 'Optional notes',
'intelligence.tasks.composer.create': 'Create task',
'intelligence.tasks.composer.creating': 'Creating…',
'intelligence.tasks.composer.createFailed': "Couldn't create the task",
'notifications.card.dismiss': 'Ignora notifica',
'notifications.card.importanceTitle': 'Importanza: {pct}%',
'notifications.center.empty': 'Nessuna notifica',
@@ -457,6 +474,7 @@ const it4: TranslationMap = {
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.deleteCard': 'Delete',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
+2
View File
@@ -565,6 +565,8 @@ const it5: TranslationMap = {
'skills.uninstall.confirmTitle': 'Disinstallare {name}?',
'conversations.taskKanban.blocked': 'Bloccato',
'conversations.taskKanban.done': 'Fatto',
'conversations.taskKanban.pending': 'Pending',
'conversations.taskKanban.working': 'Working',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
+3
View File
@@ -175,6 +175,9 @@ const ko1: TranslationMap = {
'memory.noResults': '메모리를 찾을 수 없습니다',
'memory.empty': '아직 메모리가 없습니다. 메모리는 상호작용하면서 자동으로 생성됩니다.',
'memory.tab.memory': '메모리',
'memory.tab.tasks': 'Agent Tasks',
'memory.tab.tasksDescription':
'Create and track tasks — your own to-dos plus the boards your agents build across conversations.',
'memory.tab.subconscious': '잠재의식',
'memory.tab.dreams': '꿈',
'memory.tab.calls': '통화',
+36
View File
@@ -419,6 +419,42 @@ const ko2: TranslationMap = {
'자체 Composio API 키를 가져와 호출을 backend.composio.dev로 직접 라우팅',
'devOptions.menuComposioTriggers': '통합 트리거',
'devOptions.menuComposioTriggersDesc': 'Composio 통합 트리거에 대한 AI 심사 설정 구성',
'settings.agents.title': 'Agents',
'settings.agents.subtitle':
'Manage the agents available for delegation — built-in defaults and your own custom agents.',
'settings.agents.menuDesc': 'Manage built-in and custom agents',
'settings.agents.newAgent': 'New agent',
'settings.agents.loadError': "Couldn't load agents",
'settings.agents.empty': 'No agents yet',
'settings.agents.sourceDefault': 'Built-in',
'settings.agents.sourceCustom': 'Custom',
'settings.agents.enable': 'Enable agent',
'settings.agents.disable': 'Disable agent',
'settings.agents.edit': 'Edit',
'settings.agents.delete': 'Delete',
'settings.agents.reset': 'Reset to default',
'settings.agents.modelLabel': 'Model',
'settings.agents.toolsLabel': 'Tools',
'settings.agents.toolsAll': 'All tools',
'settings.agents.toolsCount': '{count} tools',
'settings.agents.actionFailed': "Couldn't update the agent",
'settings.agents.orchestratorLocked': 'The orchestrator is always enabled.',
'settings.agents.editor.createTitle': 'New agent',
'settings.agents.editor.editTitle': 'Edit agent',
'settings.agents.editor.id': 'ID',
'settings.agents.editor.idHint': 'Lowercase letters, numbers, _ and - only.',
'settings.agents.editor.name': 'Name',
'settings.agents.editor.description': 'Description',
'settings.agents.editor.model': 'Model (optional)',
'settings.agents.editor.modelPlaceholder': 'e.g. inherit, hint:fast, or a model id',
'settings.agents.editor.systemPrompt': 'System prompt (optional)',
'settings.agents.editor.tools': 'Allowed tools',
'settings.agents.editor.toolsHint': 'One tool name per line. Use * for all tools.',
'settings.agents.editor.defaultsNote':
'Editing a built-in agent saves an override you can reset later.',
'settings.agents.editor.save': 'Save',
'settings.agents.editor.create': 'Create agent',
'settings.agents.editor.saving': 'Saving…',
};
export default ko2;
+18
View File
@@ -143,6 +143,23 @@ const ko4: TranslationMap = {
'intelligence.tasks.live': '실시간',
'intelligence.tasks.loadingBoards': '작업 보드 불러오는 중…',
'intelligence.tasks.threadPrefix': '스레드 {thread}',
'intelligence.tasks.subtitle': 'Your tasks and agent task boards across the workspace.',
'intelligence.tasks.newTask': 'New task',
'intelligence.tasks.personalBoardTitle': 'Agent Tasks',
'intelligence.tasks.personalEmpty': 'No personal tasks yet',
'intelligence.tasks.composer.title': 'New task',
'intelligence.tasks.composer.titleLabel': 'Title',
'intelligence.tasks.composer.titlePlaceholder': 'What needs to be done?',
'intelligence.tasks.composer.statusLabel': 'Status',
'intelligence.tasks.composer.attachLabel': 'Attach to conversation',
'intelligence.tasks.composer.attachNone': 'Personal (no conversation)',
'intelligence.tasks.composer.objectiveLabel': 'Objective',
'intelligence.tasks.composer.objectivePlaceholder': 'Optional — the desired outcome',
'intelligence.tasks.composer.notesLabel': 'Notes',
'intelligence.tasks.composer.notesPlaceholder': 'Optional notes',
'intelligence.tasks.composer.create': 'Create task',
'intelligence.tasks.composer.creating': 'Creating…',
'intelligence.tasks.composer.createFailed': "Couldn't create the task",
'notifications.card.dismiss': '알림 닫기',
'notifications.card.importanceTitle': '중요도: {pct}%',
'notifications.center.empty': '아직 알림이 없습니다',
@@ -455,6 +472,7 @@ const ko4: TranslationMap = {
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.deleteCard': 'Delete',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
+2
View File
@@ -436,6 +436,8 @@ const ko5: TranslationMap = {
'skills.uninstall.confirmTitle': '{name}을(를) 제거하시겠습니까?',
'conversations.taskKanban.blocked': '차단됨',
'conversations.taskKanban.done': '완료',
'conversations.taskKanban.pending': 'Pending',
'conversations.taskKanban.working': 'Working',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
+3
View File
@@ -182,6 +182,9 @@ const pt1: TranslationMap = {
'memory.empty':
'Nenhuma memória ainda. As memórias são criadas automaticamente conforme você interage.',
'memory.tab.memory': 'Memória',
'memory.tab.tasks': 'Agent Tasks',
'memory.tab.tasksDescription':
'Create and track tasks — your own to-dos plus the boards your agents build across conversations.',
'memory.tab.subconscious': 'Subconsciente',
'memory.tab.dreams': 'Sonhos',
'memory.tab.calls': 'Chamadas',
+36
View File
@@ -443,6 +443,42 @@ const pt2: TranslationMap = {
'Definir configurações de triagem de IA para gatilhos de integração Composio',
'mic.deviceSelector': 'Dispositivo de microfone',
'mic.tapToSendCountdown': 'Toque para enviar ({seconds}s)',
'settings.agents.title': 'Agents',
'settings.agents.subtitle':
'Manage the agents available for delegation — built-in defaults and your own custom agents.',
'settings.agents.menuDesc': 'Manage built-in and custom agents',
'settings.agents.newAgent': 'New agent',
'settings.agents.loadError': "Couldn't load agents",
'settings.agents.empty': 'No agents yet',
'settings.agents.sourceDefault': 'Built-in',
'settings.agents.sourceCustom': 'Custom',
'settings.agents.enable': 'Enable agent',
'settings.agents.disable': 'Disable agent',
'settings.agents.edit': 'Edit',
'settings.agents.delete': 'Delete',
'settings.agents.reset': 'Reset to default',
'settings.agents.modelLabel': 'Model',
'settings.agents.toolsLabel': 'Tools',
'settings.agents.toolsAll': 'All tools',
'settings.agents.toolsCount': '{count} tools',
'settings.agents.actionFailed': "Couldn't update the agent",
'settings.agents.orchestratorLocked': 'The orchestrator is always enabled.',
'settings.agents.editor.createTitle': 'New agent',
'settings.agents.editor.editTitle': 'Edit agent',
'settings.agents.editor.id': 'ID',
'settings.agents.editor.idHint': 'Lowercase letters, numbers, _ and - only.',
'settings.agents.editor.name': 'Name',
'settings.agents.editor.description': 'Description',
'settings.agents.editor.model': 'Model (optional)',
'settings.agents.editor.modelPlaceholder': 'e.g. inherit, hint:fast, or a model id',
'settings.agents.editor.systemPrompt': 'System prompt (optional)',
'settings.agents.editor.tools': 'Allowed tools',
'settings.agents.editor.toolsHint': 'One tool name per line. Use * for all tools.',
'settings.agents.editor.defaultsNote':
'Editing a built-in agent saves an override you can reset later.',
'settings.agents.editor.save': 'Save',
'settings.agents.editor.create': 'Create agent',
'settings.agents.editor.saving': 'Saving…',
};
export default pt2;
+18
View File
@@ -153,6 +153,23 @@ const pt4: TranslationMap = {
'intelligence.tasks.live': 'ao vivo',
'intelligence.tasks.loadingBoards': 'Carregando quadros de tarefas…',
'intelligence.tasks.threadPrefix': 'Tópico {thread}',
'intelligence.tasks.subtitle': 'Your tasks and agent task boards across the workspace.',
'intelligence.tasks.newTask': 'New task',
'intelligence.tasks.personalBoardTitle': 'Agent Tasks',
'intelligence.tasks.personalEmpty': 'No personal tasks yet',
'intelligence.tasks.composer.title': 'New task',
'intelligence.tasks.composer.titleLabel': 'Title',
'intelligence.tasks.composer.titlePlaceholder': 'What needs to be done?',
'intelligence.tasks.composer.statusLabel': 'Status',
'intelligence.tasks.composer.attachLabel': 'Attach to conversation',
'intelligence.tasks.composer.attachNone': 'Personal (no conversation)',
'intelligence.tasks.composer.objectiveLabel': 'Objective',
'intelligence.tasks.composer.objectivePlaceholder': 'Optional — the desired outcome',
'intelligence.tasks.composer.notesLabel': 'Notes',
'intelligence.tasks.composer.notesPlaceholder': 'Optional notes',
'intelligence.tasks.composer.create': 'Create task',
'intelligence.tasks.composer.creating': 'Creating…',
'intelligence.tasks.composer.createFailed': "Couldn't create the task",
'notifications.card.dismiss': 'Dispensar notificação',
'notifications.card.importanceTitle': 'Importância: {pct}%',
'notifications.center.empty': 'Nenhuma notificação ainda',
@@ -455,6 +472,7 @@ const pt4: TranslationMap = {
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.deleteCard': 'Delete',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
+2
View File
@@ -565,6 +565,8 @@ const pt5: TranslationMap = {
'skills.uninstall.confirmTitle': 'Desinstalar {name}?',
'conversations.taskKanban.blocked': 'Bloqueado',
'conversations.taskKanban.done': 'Concluído',
'conversations.taskKanban.pending': 'Pending',
'conversations.taskKanban.working': 'Working',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
+3
View File
@@ -176,6 +176,9 @@ const ru1: TranslationMap = {
'memory.noResults': 'Воспоминания не найдены',
'memory.empty': 'Воспоминаний пока нет. Они создаются автоматически в процессе общения.',
'memory.tab.memory': 'Память',
'memory.tab.tasks': 'Agent Tasks',
'memory.tab.tasksDescription':
'Create and track tasks — your own to-dos plus the boards your agents build across conversations.',
'memory.tab.subconscious': 'Подсознание',
'memory.tab.dreams': 'Сны',
'memory.tab.calls': 'Звонки',
+36
View File
@@ -438,6 +438,42 @@ const ru2: TranslationMap = {
'Настройка параметров сортировки AI для триггеров интеграции Composio',
'mic.deviceSelector': 'Микрофонное устройство',
'mic.tapToSendCountdown': 'Нажми для отправки ({seconds}с)',
'settings.agents.title': 'Agents',
'settings.agents.subtitle':
'Manage the agents available for delegation — built-in defaults and your own custom agents.',
'settings.agents.menuDesc': 'Manage built-in and custom agents',
'settings.agents.newAgent': 'New agent',
'settings.agents.loadError': "Couldn't load agents",
'settings.agents.empty': 'No agents yet',
'settings.agents.sourceDefault': 'Built-in',
'settings.agents.sourceCustom': 'Custom',
'settings.agents.enable': 'Enable agent',
'settings.agents.disable': 'Disable agent',
'settings.agents.edit': 'Edit',
'settings.agents.delete': 'Delete',
'settings.agents.reset': 'Reset to default',
'settings.agents.modelLabel': 'Model',
'settings.agents.toolsLabel': 'Tools',
'settings.agents.toolsAll': 'All tools',
'settings.agents.toolsCount': '{count} tools',
'settings.agents.actionFailed': "Couldn't update the agent",
'settings.agents.orchestratorLocked': 'The orchestrator is always enabled.',
'settings.agents.editor.createTitle': 'New agent',
'settings.agents.editor.editTitle': 'Edit agent',
'settings.agents.editor.id': 'ID',
'settings.agents.editor.idHint': 'Lowercase letters, numbers, _ and - only.',
'settings.agents.editor.name': 'Name',
'settings.agents.editor.description': 'Description',
'settings.agents.editor.model': 'Model (optional)',
'settings.agents.editor.modelPlaceholder': 'e.g. inherit, hint:fast, or a model id',
'settings.agents.editor.systemPrompt': 'System prompt (optional)',
'settings.agents.editor.tools': 'Allowed tools',
'settings.agents.editor.toolsHint': 'One tool name per line. Use * for all tools.',
'settings.agents.editor.defaultsNote':
'Editing a built-in agent saves an override you can reset later.',
'settings.agents.editor.save': 'Save',
'settings.agents.editor.create': 'Create agent',
'settings.agents.editor.saving': 'Saving…',
};
export default ru2;
+18
View File
@@ -152,6 +152,23 @@ const ru4: TranslationMap = {
'intelligence.tasks.live': 'в реальном времени',
'intelligence.tasks.loadingBoards': 'Загрузка досок задач…',
'intelligence.tasks.threadPrefix': 'Тред {thread}',
'intelligence.tasks.subtitle': 'Your tasks and agent task boards across the workspace.',
'intelligence.tasks.newTask': 'New task',
'intelligence.tasks.personalBoardTitle': 'Agent Tasks',
'intelligence.tasks.personalEmpty': 'No personal tasks yet',
'intelligence.tasks.composer.title': 'New task',
'intelligence.tasks.composer.titleLabel': 'Title',
'intelligence.tasks.composer.titlePlaceholder': 'What needs to be done?',
'intelligence.tasks.composer.statusLabel': 'Status',
'intelligence.tasks.composer.attachLabel': 'Attach to conversation',
'intelligence.tasks.composer.attachNone': 'Personal (no conversation)',
'intelligence.tasks.composer.objectiveLabel': 'Objective',
'intelligence.tasks.composer.objectivePlaceholder': 'Optional — the desired outcome',
'intelligence.tasks.composer.notesLabel': 'Notes',
'intelligence.tasks.composer.notesPlaceholder': 'Optional notes',
'intelligence.tasks.composer.create': 'Create task',
'intelligence.tasks.composer.creating': 'Creating…',
'intelligence.tasks.composer.createFailed': "Couldn't create the task",
'notifications.card.dismiss': 'Закрыть уведомление',
'notifications.card.importanceTitle': 'Важность: {pct}%',
'notifications.center.empty': 'Уведомлений пока нет',
@@ -452,6 +469,7 @@ const ru4: TranslationMap = {
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.deleteCard': 'Delete',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
+2
View File
@@ -561,6 +561,8 @@ const ru5: TranslationMap = {
'skills.uninstall.confirmTitle': 'Удалить {name}?',
'conversations.taskKanban.blocked': 'Заблокировано',
'conversations.taskKanban.done': 'Готово',
'conversations.taskKanban.pending': 'Pending',
'conversations.taskKanban.working': 'Working',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
+3
View File
@@ -171,6 +171,9 @@ const zhCN1: TranslationMap = {
'memory.noResults': '未找到记忆',
'memory.empty': '暂无记忆。记忆将在你交互时自动创建。',
'memory.tab.memory': '记忆',
'memory.tab.tasks': 'Agent Tasks',
'memory.tab.tasksDescription':
'Create and track tasks — your own to-dos plus the boards your agents build across conversations.',
'memory.tab.subconscious': '潜意识',
'memory.tab.dreams': '梦境',
'memory.tab.calls': '调用记录',
+36
View File
@@ -406,6 +406,42 @@ const zhCN2: TranslationMap = {
'devOptions.menuComposioTriggersDesc': '为 Composio 集成触发器配置 AI 分级设置',
'mic.deviceSelector': '麦克风装置',
'mic.tapToSendCountdown': '点击发送 ({seconds}秒)',
'settings.agents.title': 'Agents',
'settings.agents.subtitle':
'Manage the agents available for delegation — built-in defaults and your own custom agents.',
'settings.agents.menuDesc': 'Manage built-in and custom agents',
'settings.agents.newAgent': 'New agent',
'settings.agents.loadError': "Couldn't load agents",
'settings.agents.empty': 'No agents yet',
'settings.agents.sourceDefault': 'Built-in',
'settings.agents.sourceCustom': 'Custom',
'settings.agents.enable': 'Enable agent',
'settings.agents.disable': 'Disable agent',
'settings.agents.edit': 'Edit',
'settings.agents.delete': 'Delete',
'settings.agents.reset': 'Reset to default',
'settings.agents.modelLabel': 'Model',
'settings.agents.toolsLabel': 'Tools',
'settings.agents.toolsAll': 'All tools',
'settings.agents.toolsCount': '{count} tools',
'settings.agents.actionFailed': "Couldn't update the agent",
'settings.agents.orchestratorLocked': 'The orchestrator is always enabled.',
'settings.agents.editor.createTitle': 'New agent',
'settings.agents.editor.editTitle': 'Edit agent',
'settings.agents.editor.id': 'ID',
'settings.agents.editor.idHint': 'Lowercase letters, numbers, _ and - only.',
'settings.agents.editor.name': 'Name',
'settings.agents.editor.description': 'Description',
'settings.agents.editor.model': 'Model (optional)',
'settings.agents.editor.modelPlaceholder': 'e.g. inherit, hint:fast, or a model id',
'settings.agents.editor.systemPrompt': 'System prompt (optional)',
'settings.agents.editor.tools': 'Allowed tools',
'settings.agents.editor.toolsHint': 'One tool name per line. Use * for all tools.',
'settings.agents.editor.defaultsNote':
'Editing a built-in agent saves an override you can reset later.',
'settings.agents.editor.save': 'Save',
'settings.agents.editor.create': 'Create agent',
'settings.agents.editor.saving': 'Saving…',
};
export default zhCN2;
+18
View File
@@ -149,6 +149,23 @@ const zhCN4: TranslationMap = {
'intelligence.tasks.live': '实时',
'intelligence.tasks.loadingBoards': '正在加载任务看板…',
'intelligence.tasks.threadPrefix': '对话',
'intelligence.tasks.subtitle': 'Your tasks and agent task boards across the workspace.',
'intelligence.tasks.newTask': 'New task',
'intelligence.tasks.personalBoardTitle': 'Agent Tasks',
'intelligence.tasks.personalEmpty': 'No personal tasks yet',
'intelligence.tasks.composer.title': 'New task',
'intelligence.tasks.composer.titleLabel': 'Title',
'intelligence.tasks.composer.titlePlaceholder': 'What needs to be done?',
'intelligence.tasks.composer.statusLabel': 'Status',
'intelligence.tasks.composer.attachLabel': 'Attach to conversation',
'intelligence.tasks.composer.attachNone': 'Personal (no conversation)',
'intelligence.tasks.composer.objectiveLabel': 'Objective',
'intelligence.tasks.composer.objectivePlaceholder': 'Optional — the desired outcome',
'intelligence.tasks.composer.notesLabel': 'Notes',
'intelligence.tasks.composer.notesPlaceholder': 'Optional notes',
'intelligence.tasks.composer.create': 'Create task',
'intelligence.tasks.composer.creating': 'Creating…',
'intelligence.tasks.composer.createFailed': "Couldn't create the task",
'notifications.card.dismiss': '忽略通知',
'notifications.card.importanceTitle': '重要性',
'notifications.center.empty': '暂无通知',
@@ -443,6 +460,7 @@ const zhCN4: TranslationMap = {
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.deleteCard': 'Delete',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
};
+2
View File
@@ -533,6 +533,8 @@ const zhCN5: TranslationMap = {
'skills.uninstall.confirmTitle': '卸载 {name}',
'conversations.taskKanban.blocked': '已阻塞',
'conversations.taskKanban.done': '已完成',
'conversations.taskKanban.pending': 'Pending',
'conversations.taskKanban.working': 'Working',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
+59
View File
@@ -292,6 +292,9 @@ const en: TranslationMap = {
'memory.noResults': 'No memories found',
'memory.empty': 'No memories yet. Memories are created automatically as you interact.',
'memory.tab.memory': 'Memory',
'memory.tab.tasks': 'Agent Tasks',
'memory.tab.tasksDescription':
'Create and track tasks — your own to-dos plus the boards your agents build across conversations.',
'memory.tab.subconscious': 'Subconscious',
'memory.tab.dreams': 'Dreams',
'memory.tab.calls': 'Calls',
@@ -2548,6 +2551,7 @@ const en: TranslationMap = {
'conversations.taskKanban.field.status': 'Status',
'conversations.taskKanban.field.title': 'Title',
'conversations.taskKanban.saveChanges': 'Save changes',
'conversations.taskKanban.deleteCard': 'Delete',
'conversations.taskKanban.updateFailed': 'Could not update task; changes were not saved.',
'conversations.toolTimeline.turn': 'turn',
'conversations.toolTimeline.workerThread': 'worker thread',
@@ -2636,6 +2640,23 @@ const en: TranslationMap = {
'intelligence.tasks.live': 'live',
'intelligence.tasks.loadingBoards': 'Loading task boards…',
'intelligence.tasks.threadPrefix': 'Thread {id}',
'intelligence.tasks.subtitle': 'Your tasks and agent task boards across the workspace.',
'intelligence.tasks.newTask': 'New task',
'intelligence.tasks.personalBoardTitle': 'Agent Tasks',
'intelligence.tasks.personalEmpty': 'No personal tasks yet',
'intelligence.tasks.composer.title': 'New task',
'intelligence.tasks.composer.titleLabel': 'Title',
'intelligence.tasks.composer.titlePlaceholder': 'What needs to be done?',
'intelligence.tasks.composer.statusLabel': 'Status',
'intelligence.tasks.composer.attachLabel': 'Attach to conversation',
'intelligence.tasks.composer.attachNone': 'Personal (no conversation)',
'intelligence.tasks.composer.objectiveLabel': 'Objective',
'intelligence.tasks.composer.objectivePlaceholder': 'Optional — the desired outcome',
'intelligence.tasks.composer.notesLabel': 'Notes',
'intelligence.tasks.composer.notesPlaceholder': 'Optional notes',
'intelligence.tasks.composer.create': 'Create task',
'intelligence.tasks.composer.creating': 'Creating…',
'intelligence.tasks.composer.createFailed': "Couldn't create the task",
'notifications.card.dismiss': 'Dismiss notification',
'notifications.card.importanceTitle': 'Importance: {pct}%',
'notifications.center.empty': 'No notifications yet',
@@ -4025,6 +4046,8 @@ const en: TranslationMap = {
'skills.uninstall.confirmTitle': 'Uninstall {name}?',
'conversations.taskKanban.blocked': 'Blocked',
'conversations.taskKanban.done': 'Done',
'conversations.taskKanban.pending': 'Pending',
'conversations.taskKanban.working': 'Working',
'conversations.taskKanban.awaitingApproval': 'Awaiting approval',
'conversations.taskKanban.ready': 'Ready',
'conversations.taskKanban.rejected': 'Rejected',
@@ -4199,6 +4222,42 @@ const en: TranslationMap = {
'skills.new.title': 'Create a skill',
'skills.new.placeholderBody':
'Authoring form arrives soon. For now, use the “New skill” button on the runner page.',
'settings.agents.title': 'Agents',
'settings.agents.subtitle':
'Manage the agents available for delegation — built-in defaults and your own custom agents.',
'settings.agents.menuDesc': 'Manage built-in and custom agents',
'settings.agents.newAgent': 'New agent',
'settings.agents.loadError': "Couldn't load agents",
'settings.agents.empty': 'No agents yet',
'settings.agents.sourceDefault': 'Built-in',
'settings.agents.sourceCustom': 'Custom',
'settings.agents.enable': 'Enable agent',
'settings.agents.disable': 'Disable agent',
'settings.agents.edit': 'Edit',
'settings.agents.delete': 'Delete',
'settings.agents.reset': 'Reset to default',
'settings.agents.modelLabel': 'Model',
'settings.agents.toolsLabel': 'Tools',
'settings.agents.toolsAll': 'All tools',
'settings.agents.toolsCount': '{count} tools',
'settings.agents.actionFailed': "Couldn't update the agent",
'settings.agents.orchestratorLocked': 'The orchestrator is always enabled.',
'settings.agents.editor.createTitle': 'New agent',
'settings.agents.editor.editTitle': 'Edit agent',
'settings.agents.editor.id': 'ID',
'settings.agents.editor.idHint': 'Lowercase letters, numbers, _ and - only.',
'settings.agents.editor.name': 'Name',
'settings.agents.editor.description': 'Description',
'settings.agents.editor.model': 'Model (optional)',
'settings.agents.editor.modelPlaceholder': 'e.g. inherit, hint:fast, or a model id',
'settings.agents.editor.systemPrompt': 'System prompt (optional)',
'settings.agents.editor.tools': 'Allowed tools',
'settings.agents.editor.toolsHint': 'One tool name per line. Use * for all tools.',
'settings.agents.editor.defaultsNote':
'Editing a built-in agent saves an override you can reset later.',
'settings.agents.editor.save': 'Save',
'settings.agents.editor.create': 'Create agent',
'settings.agents.editor.saving': 'Saving…',
};
export default en;
+21 -27
View File
@@ -3,8 +3,6 @@ import { useCallback, useEffect, useState } from 'react';
import { ConfirmationModal } from '../components/intelligence/ConfirmationModal';
import DiagramViewerTab from '../components/intelligence/DiagramViewerTab';
import GraphCentralityTab from '../components/intelligence/GraphCentralityTab';
import IntelligenceCallsTab from '../components/intelligence/IntelligenceCallsTab';
import IntelligenceDreamsTab from '../components/intelligence/IntelligenceDreamsTab';
import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab';
import IntelligenceTasksTab from '../components/intelligence/IntelligenceTasksTab';
import { MemoryWorkspace } from '../components/intelligence/MemoryWorkspace';
@@ -21,19 +19,12 @@ import type {
ToastNotification,
} from '../types/intelligence';
type IntelligenceTab =
| 'memory'
| 'subconscious'
| 'calls'
| 'dreams'
| 'tasks'
| 'diagram'
| 'centrality';
type IntelligenceTab = 'memory' | 'subconscious' | 'tasks' | 'diagram' | 'centrality';
export default function Intelligence() {
const { t } = useT();
const [activeTab, setActiveTab] = useState<IntelligenceTab>('memory');
const [activeTab, setActiveTab] = useState<IntelligenceTab>('tasks');
// The legacy header pills (system-status + Ingesting/Queued chips) were
// sourced from `useConsciousItems` + `useMemoryIngestionStatus`. They are
@@ -96,15 +87,15 @@ export default function Intelligence() {
}
}, [socketConnected, socketManager]);
const tabs: { id: IntelligenceTab; label: string; comingSoon?: boolean }[] = [
{ id: 'memory', label: t('memory.tab.memory') },
{ id: 'subconscious', label: t('memory.tab.subconscious') },
{ id: 'tasks', label: 'Tasks' },
{ id: 'diagram', label: t('memory.tab.diagram') },
{ id: 'calls', label: t('memory.tab.calls') },
{ id: 'dreams', label: t('memory.tab.dreams') },
{ id: 'centrality', label: t('memory.tab.centrality') },
];
const tabs: { id: IntelligenceTab; label: string; description?: string; comingSoon?: boolean }[] =
[
{ id: 'tasks', label: t('memory.tab.tasks'), description: t('memory.tab.tasksDescription') },
{ id: 'memory', label: t('memory.tab.memory') },
{ id: 'subconscious', label: t('memory.tab.subconscious') },
{ id: 'diagram', label: t('memory.tab.diagram') },
{ id: 'centrality', label: t('memory.tab.centrality') },
];
const activeTabDef = tabs.find(tab => tab.id === activeTab);
return (
<div className="min-h-full p-4 pt-6">
@@ -136,14 +127,21 @@ export default function Intelligence() {
<div className="bg-white dark:bg-neutral-900 rounded-2xl shadow-soft border border-stone-200 dark:border-neutral-800 p-6">
<div>
{/* Header */}
{/* Header reflects the active tab so the panel title matches
what's shown below it (e.g. "Agent Tasks" on the Tasks tab),
rather than a static "Memory". */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="min-w-0">
<h1
className="text-xl font-bold text-stone-900 dark:text-neutral-100"
data-walkthrough="intelligence-header">
{t('memory.title')}
{activeTabDef?.label ?? t('memory.title')}
</h1>
{activeTabDef?.description && (
<p className="mt-1 text-sm text-stone-500 dark:text-neutral-400">
{activeTabDef.description}
</p>
)}
{/* Header count badge was sourced from `stats.total` which
in turn came from the legacy actionable-items pipeline
(`filterItems(items, ...)`). The Memory tab now mounts
@@ -183,10 +181,6 @@ export default function Intelligence() {
{activeTab === 'diagram' && <DiagramViewerTab />}
{activeTab === 'calls' && <IntelligenceCallsTab onToast={addToast} />}
{activeTab === 'dreams' && <IntelligenceDreamsTab />}
{activeTab === 'centrality' && <GraphCentralityTab />}
</div>
</div>
+2
View File
@@ -6,6 +6,7 @@ import LogoutAndClearActions from '../components/settings/LogoutAndClearActions'
import AboutPanel from '../components/settings/panels/AboutPanel';
import AgentAccessPanel from '../components/settings/panels/AgentAccessPanel';
import AgentChatPanel from '../components/settings/panels/AgentChatPanel';
import AgentsPanel from '../components/settings/panels/AgentsPanel';
import AIPanel from '../components/settings/panels/AIPanel';
import AppearancePanel from '../components/settings/panels/AppearancePanel';
import AutocompleteDebugPanel from '../components/settings/panels/AutocompleteDebugPanel';
@@ -434,6 +435,7 @@ const Settings = () => {
<Route path="persona" element={wrapSettingsPage(<PersonaPanel />)} />
<Route path="appearance" element={wrapSettingsPage(<AppearancePanel />)} />
<Route path="agent-access" element={wrapSettingsPage(<AgentAccessPanel />)} />
<Route path="agents" element={wrapSettingsPage(<AgentsPanel />)} />
<Route path="tools" element={wrapSettingsPage(<ToolsPanel />)} />
<Route path="companion" element={wrapSettingsPage(<CompanionPanel />)} />
{/* Developer Options */}
@@ -16,14 +16,6 @@ vi.mock('../../components/intelligence/IntelligenceTasksTab', () => ({
default: () => <div>Tasks tab</div>,
}));
vi.mock('../../components/intelligence/IntelligenceCallsTab', () => ({
default: () => <div>Calls tab</div>,
}));
vi.mock('../../components/intelligence/IntelligenceDreamsTab', () => ({
default: () => <div>Dreams tab</div>,
}));
vi.mock('../../hooks/useConsciousItems', () => ({
useConsciousItems: () => ({ isRunning: false }),
}));
@@ -15,13 +15,18 @@ import type { TaskBoard, TaskBoardCard, TaskBoardCardStatus } from '../../../typ
type ColumnDef = { status: TaskBoardCardStatus; labelKey: string };
// The board surfaces exactly three columns — Pending / Working / Done. The
// richer status set the core tracks (approval flow, blocked, rejected) is
// bucketed into these three via `columnFor`.
const COLUMN_DEFS: ColumnDef[] = [
{ status: 'todo', labelKey: 'conversations.taskKanban.todo' },
{ status: 'in_progress', labelKey: 'conversations.taskKanban.inProgress' },
{ status: 'blocked', labelKey: 'conversations.taskKanban.blocked' },
{ status: 'todo', labelKey: 'conversations.taskKanban.pending' },
{ status: 'in_progress', labelKey: 'conversations.taskKanban.working' },
{ status: 'done', labelKey: 'conversations.taskKanban.done' },
];
/** The three statuses a user can set directly from the board. */
const COLUMN_STATUSES = COLUMN_DEFS.map(column => column.status);
const STATUS_INDEX = new Map(COLUMN_DEFS.map((column, index) => [column.status, index]));
/** Label key for *every* status, including the approval-flow statuses that
@@ -31,33 +36,32 @@ const STATUS_INDEX = new Map(COLUMN_DEFS.map((column, index) => [column.status,
* React warns about and which renders as the first option, hiding the real
* status from the user). */
const STATUS_LABEL_KEYS: Record<TaskBoardCardStatus, string> = {
todo: 'conversations.taskKanban.todo',
todo: 'conversations.taskKanban.pending',
awaiting_approval: 'conversations.taskKanban.awaitingApproval',
ready: 'conversations.taskKanban.ready',
in_progress: 'conversations.taskKanban.inProgress',
in_progress: 'conversations.taskKanban.working',
blocked: 'conversations.taskKanban.blocked',
done: 'conversations.taskKanban.done',
rejected: 'conversations.taskKanban.rejected',
};
const ALL_STATUSES = Object.keys(STATUS_LABEL_KEYS) as TaskBoardCardStatus[];
/** Whether a status owns a kanban column (vs the approval-flow statuses that
* are bucketed into an existing column). */
/** Whether a status owns a kanban column (vs the approval-flow / terminal
* statuses that are bucketed into an existing column). */
function isColumnStatus(status: TaskBoardCardStatus): boolean {
return STATUS_INDEX.has(status);
}
/** Map a card status to the column it renders under. The approval-flow
* statuses don't get their own columns: pre-execution ones sit in `todo`,
* `rejected` sits with `blocked`. */
/** Map a card status to the column it renders under. Pre-execution approval
* statuses sit in `Pending`; `blocked` and `rejected` are surfaced under
* `Done` so the board stays a clean three-column Pending / Working / Done. */
function columnFor(status: TaskBoardCardStatus): TaskBoardCardStatus {
switch (status) {
case 'awaiting_approval':
case 'ready':
return 'todo';
case 'blocked':
case 'rejected':
return 'blocked';
return 'done';
default:
return status;
}
@@ -66,8 +70,12 @@ function columnFor(status: TaskBoardCardStatus): TaskBoardCardStatus {
interface TaskKanbanBoardProps {
board: TaskBoard;
disabled?: boolean;
/** Hide the board's own "Tasks" title row used where the caller already
* renders a heading for the board, to avoid a doubled-up title. */
hideHeader?: boolean;
onMove?: (card: TaskBoardCard, status: TaskBoardCardStatus) => void;
onUpdateCard?: (card: TaskBoardCard, nextCard: TaskBoardCard) => void;
onDeleteCard?: (card: TaskBoardCard) => void;
/** Approve/reject a card awaiting plan approval. */
onDecidePlan?: (card: TaskBoardCard, approve: boolean) => void;
}
@@ -75,8 +83,10 @@ interface TaskKanbanBoardProps {
export function TaskKanbanBoard({
board,
disabled = false,
hideHeader = false,
onMove,
onUpdateCard,
onDeleteCard,
onDecidePlan,
}: TaskKanbanBoardProps) {
const { t } = useT();
@@ -108,16 +118,18 @@ export function TaskKanbanBoard({
};
return (
<div className="rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-3 shadow-sm">
<div className="mb-2 flex items-center justify-between gap-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('conversations.taskKanban.title')}
</h4>
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
{board.cards.length}
</span>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-4">
<div className="py-3">
{!hideHeader && (
<div className="mb-2 flex items-center justify-between gap-3">
<h4 className="text-xs font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('conversations.taskKanban.title')}
</h4>
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
{board.cards.length}
</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
{COLUMN_DEFS.map(column => (
<section
key={column.status}
@@ -225,6 +237,7 @@ export function TaskKanbanBoard({
</p>
)}
{(onUpdateCard ||
onDeleteCard ||
card.plan?.length ||
card.allowedTools?.length ||
card.acceptanceCriteria?.length ||
@@ -252,6 +265,7 @@ export function TaskKanbanBoard({
disabled={disabled}
onClose={() => setSelectedCardId(null)}
onUpdate={onUpdateCard}
onDelete={onDeleteCard}
/>
)}
</div>
@@ -263,14 +277,23 @@ function TaskBriefDialog({
disabled,
onClose,
onUpdate,
onDelete,
}: {
card: TaskBoardCard;
disabled: boolean;
onClose: () => void;
onUpdate?: (card: TaskBoardCard, nextCard: TaskBoardCard) => void;
onDelete?: (card: TaskBoardCard) => void;
}) {
const { t } = useT();
const editable = Boolean(onUpdate) && !disabled;
const deletable = Boolean(onDelete) && !disabled;
const handleDelete = () => {
if (!deletable) return;
onDelete?.(card);
onClose();
};
const [title, setTitle] = useState(card.title);
const [status, setStatus] = useState<TaskBoardCardStatus>(card.status);
const [objective, setObjective] = useState(card.objective ?? '');
@@ -347,7 +370,10 @@ function TaskBriefDialog({
value={status}
onChange={e => setStatus(e.target.value as TaskBoardCardStatus)}
className="w-full rounded-md border border-stone-200 bg-white px-2 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-950 dark:text-neutral-50">
{ALL_STATUSES.map(s => (
{(COLUMN_STATUSES.includes(status)
? COLUMN_STATUSES
: [status, ...COLUMN_STATUSES]
).map(s => (
<option key={s} value={s}>
{t(STATUS_LABEL_KEYS[s])}
</option>
@@ -412,20 +438,32 @@ function TaskBriefDialog({
value={blocker}
onChange={setBlocker}
/>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={onClose}
className="rounded-md border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800">
{t('common.cancel')}
</button>
<button
type="button"
onClick={save}
disabled={!title.trim()}
className="rounded-md bg-ocean-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-700 disabled:opacity-50">
{t('conversations.taskKanban.saveChanges')}
</button>
<div className="flex items-center justify-between gap-2 pt-1">
{deletable ? (
<button
type="button"
onClick={handleDelete}
className="rounded-md border border-coral-200 px-3 py-1.5 text-xs font-medium text-coral-600 hover:bg-coral-50 dark:border-coral-500/30 dark:text-coral-300 dark:hover:bg-coral-500/10">
{t('conversations.taskKanban.deleteCard')}
</button>
) : (
<span />
)}
<div className="flex gap-2">
<button
type="button"
onClick={onClose}
className="rounded-md border border-stone-200 px-3 py-1.5 text-xs font-medium text-stone-600 hover:bg-stone-50 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800">
{t('common.cancel')}
</button>
<button
type="button"
onClick={save}
disabled={!title.trim()}
className="rounded-md bg-ocean-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-ocean-700 disabled:opacity-50">
{t('conversations.taskKanban.saveChanges')}
</button>
</div>
</div>
</div>
) : (
@@ -473,6 +511,16 @@ function TaskBriefDialog({
value={card.blocker}
tone="danger"
/>
{deletable && (
<div className="flex justify-end pt-1">
<button
type="button"
onClick={handleDelete}
className="rounded-md border border-coral-200 px-3 py-1.5 text-xs font-medium text-coral-600 hover:bg-coral-50 dark:border-coral-500/30 dark:text-coral-300 dark:hover:bg-coral-500/10">
{t('conversations.taskKanban.deleteCard')}
</button>
</div>
)}
</div>
)}
</section>
@@ -35,14 +35,18 @@ const board: TaskBoard = {
};
describe('TaskKanbanBoard', () => {
it('renders kanban columns, cards, notes, and blockers', () => {
it('renders the three columns, cards, notes, and blockers', () => {
render(<TaskKanbanBoard board={board} />);
expect(screen.getByText('To do')).toBeInTheDocument();
expect(screen.getByText('In progress')).toBeInTheDocument();
expect(screen.getByText('Blocked')).toBeInTheDocument();
// The board surfaces exactly three columns; `blocked` is bucketed into Done.
expect(screen.getByText('Pending')).toBeInTheDocument();
expect(screen.getByText('Working')).toBeInTheDocument();
expect(screen.getByText('Done')).toBeInTheDocument();
expect(screen.queryByText('To do')).not.toBeInTheDocument();
expect(screen.queryByText('Blocked')).not.toBeInTheDocument();
expect(screen.getByText('Draft plan')).toBeInTheDocument();
// The blocked card is still rendered (under Done) with its blocker reason.
expect(screen.getByText('Wait for token')).toBeInTheDocument();
expect(screen.getByText('Prepare the implementation handoff')).toBeInTheDocument();
expect(screen.getByText('planner')).toBeInTheDocument();
expect(screen.getByText('approval')).toBeInTheDocument();
@@ -0,0 +1,90 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { callCoreRpc } from '../coreRpcClient';
import { agentRegistryApi, type AgentRegistryEntry } from './agentRegistryApi';
vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
const mockCall = vi.mocked(callCoreRpc);
function agent(overrides: Partial<AgentRegistryEntry> = {}): AgentRegistryEntry {
return {
id: 'researcher',
name: 'Researcher',
description: 'Looks things up.',
source: 'default',
enabled: true,
tool_allowlist: ['*'],
...overrides,
};
}
describe('agentRegistryApi', () => {
beforeEach(() => vi.clearAllMocks());
it('list passes include_disabled and returns the agents array', async () => {
mockCall.mockResolvedValueOnce({ agents: [agent()] });
const res = await agentRegistryApi.list(true);
expect(mockCall).toHaveBeenCalledWith({
method: 'openhuman.agent_registry_list',
params: { include_disabled: true },
});
expect(res).toHaveLength(1);
expect(res[0].id).toBe('researcher');
});
it('list tolerates a missing agents field', async () => {
mockCall.mockResolvedValueOnce({});
expect(await agentRegistryApi.list()).toEqual([]);
});
it('createCustom prunes undefined fields', async () => {
mockCall.mockResolvedValueOnce({ agent: agent({ id: 'finance', source: 'custom' }) });
await agentRegistryApi.createCustom({
id: 'finance',
name: 'Finance',
description: 'Money stuff.',
model: null,
tool_allowlist: ['memory.search'],
});
expect(mockCall).toHaveBeenCalledWith({
method: 'openhuman.agent_registry_create_custom',
params: {
id: 'finance',
name: 'Finance',
description: 'Money stuff.',
model: null,
tool_allowlist: ['memory.search'],
},
});
});
it('update forwards id plus the patch', async () => {
mockCall.mockResolvedValueOnce({ agent: agent({ name: 'Renamed' }) });
const res = await agentRegistryApi.update('researcher', { name: 'Renamed' });
expect(mockCall).toHaveBeenCalledWith({
method: 'openhuman.agent_registry_update',
params: { id: 'researcher', name: 'Renamed' },
});
expect(res.name).toBe('Renamed');
});
it('setEnabled sends id + enabled', async () => {
mockCall.mockResolvedValueOnce({ agent: agent({ enabled: false }) });
const res = await agentRegistryApi.setEnabled('researcher', false);
expect(mockCall).toHaveBeenLastCalledWith({
method: 'openhuman.agent_registry_set_enabled',
params: { id: 'researcher', enabled: false },
});
expect(res.enabled).toBe(false);
});
it('remove returns the boolean outcome', async () => {
mockCall.mockResolvedValueOnce({ removed: true });
expect(await agentRegistryApi.remove('finance')).toBe(true);
expect(mockCall).toHaveBeenLastCalledWith({
method: 'openhuman.agent_registry_remove',
params: { id: 'finance' },
});
});
});
+133
View File
@@ -0,0 +1,133 @@
/**
* Frontend client for the user-facing agent registry
* (`openhuman.agent_registry_*`). Surfaces the shipped default agents plus
* user-authored custom agents, their enable/disable state, and tool policy.
*
* Wire shape note: the Rust handlers return the bare controller payload
* (RpcOutcome `into_cli_compatible_json` serializes `data` directly), so
* `agent_registry_list` resolves to `{ agents }`, the mutating calls to
* `{ agent }`, and remove to `{ removed }`. Entries serialize with
* snake_case fields (no serde rename), so the TS shape matches that.
*/
import debug from 'debug';
import { callCoreRpc } from '../coreRpcClient';
const log = debug('agentRegistryApi');
export type AgentRegistrySource = 'default' | 'custom';
/** Mirror of the Rust `AgentRegistryEntry` (snake_case on the wire). */
export interface AgentRegistryEntry {
id: string;
name: string;
description: string;
source: AgentRegistrySource;
enabled: boolean;
model?: string | null;
system_prompt?: string | null;
tool_allowlist?: string[];
tool_denylist?: string[];
subagents?: string[];
tags?: string[];
metadata?: unknown;
}
/** Fields accepted when creating a custom agent. */
export interface CreateCustomAgentInput {
id: string;
name: string;
description: string;
enabled?: boolean;
model?: string | null;
system_prompt?: string | null;
tool_allowlist?: string[];
tool_denylist?: string[];
subagents?: string[];
tags?: string[];
}
/** Patch for `update` — any omitted field is left unchanged. */
export interface UpdateAgentInput {
name?: string;
description?: string;
enabled?: boolean;
model?: string | null;
system_prompt?: string | null;
tool_allowlist?: string[];
tool_denylist?: string[];
subagents?: string[];
tags?: string[];
}
function pruneParams(params: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) out[key] = value;
}
return out;
}
export const agentRegistryApi = {
/** List registry entries (default-first). Includes disabled by default so
* the management UI can show and re-enable them. */
list: async (includeDisabled = true): Promise<AgentRegistryEntry[]> => {
log('list includeDisabled=%s', includeDisabled);
const res = await callCoreRpc<{ agents?: AgentRegistryEntry[] }>({
method: 'openhuman.agent_registry_list',
params: { include_disabled: includeDisabled },
});
return res?.agents ?? [];
},
/** Fetch a single entry by id. */
get: async (id: string): Promise<AgentRegistryEntry | null> => {
log('get id=%s', id);
const res = await callCoreRpc<{ agent?: AgentRegistryEntry | null }>({
method: 'openhuman.agent_registry_get',
params: { id },
});
return res?.agent ?? null;
},
/** Create (or replace) a custom user-authored agent. */
createCustom: async (input: CreateCustomAgentInput): Promise<AgentRegistryEntry> => {
log('createCustom id=%s', input.id);
const res = await callCoreRpc<{ agent: AgentRegistryEntry }>({
method: 'openhuman.agent_registry_create_custom',
params: pruneParams({ ...input }),
});
return res.agent;
},
/** Patch a custom agent or override a default agent. */
update: async (id: string, patch: UpdateAgentInput): Promise<AgentRegistryEntry> => {
log('update id=%s', id);
const res = await callCoreRpc<{ agent: AgentRegistryEntry }>({
method: 'openhuman.agent_registry_update',
params: pruneParams({ id, ...patch }),
});
return res.agent;
},
/** Enable or disable an agent (default or custom). */
setEnabled: async (id: string, enabled: boolean): Promise<AgentRegistryEntry> => {
log('setEnabled id=%s enabled=%s', id, enabled);
const res = await callCoreRpc<{ agent: AgentRegistryEntry }>({
method: 'openhuman.agent_registry_set_enabled',
params: { id, enabled },
});
return res.agent;
},
/** Remove a custom agent, or reset a default-agent override. Returns
* whether a configured entry was actually removed. */
remove: async (id: string): Promise<boolean> => {
log('remove id=%s', id);
const res = await callCoreRpc<{ removed?: boolean }>({
method: 'openhuman.agent_registry_remove',
params: { id },
});
return Boolean(res?.removed);
},
};
+104
View File
@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { callCoreRpc } from '../coreRpcClient';
import { todosApi, USER_TASKS_THREAD_ID } from './todosApi';
vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
const mockCall = vi.mocked(callCoreRpc);
function snapshot(cards: unknown[], threadId: string | null = USER_TASKS_THREAD_ID) {
return { threadId, cards, markdown: '' };
}
describe('todosApi', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('exposes a stable reserved personal board id', () => {
expect(USER_TASKS_THREAD_ID).toBe('user-tasks');
});
it('list maps a snapshot into a TaskBoard and derives updatedAt from the latest card', async () => {
mockCall.mockResolvedValueOnce(
snapshot([
{ id: 'a', title: 'A', status: 'todo', order: 0, updatedAt: '2026-01-01T00:00:00Z' },
{ id: 'b', title: 'B', status: 'done', order: 1, updatedAt: '2026-02-01T00:00:00Z' },
])
);
const board = await todosApi.list(USER_TASKS_THREAD_ID);
expect(mockCall).toHaveBeenCalledWith({
method: 'openhuman.todos_list',
params: { thread_id: USER_TASKS_THREAD_ID },
});
expect(board.threadId).toBe(USER_TASKS_THREAD_ID);
expect(board.cards).toHaveLength(2);
expect(board.updatedAt).toBe('2026-02-01T00:00:00Z');
});
it('add omits undefined fields but preserves explicit nulls', async () => {
mockCall.mockResolvedValueOnce(snapshot([]));
await todosApi.add({
threadId: USER_TASKS_THREAD_ID,
content: 'Buy milk',
status: 'todo',
objective: null,
});
expect(mockCall).toHaveBeenCalledWith({
method: 'openhuman.todos_add',
params: {
thread_id: USER_TASKS_THREAD_ID,
content: 'Buy milk',
status: 'todo',
objective: null,
},
});
// `notes` was undefined → pruned from the wire params.
const params = mockCall.mock.calls[0][0].params as Record<string, unknown>;
expect('notes' in params).toBe(false);
});
it('edit forwards camelCase patch fields and a clearing null approvalMode', async () => {
mockCall.mockResolvedValueOnce(snapshot([]));
await todosApi.edit({
threadId: 't-1',
id: 'card-1',
content: 'New title',
approvalMode: null,
allowedTools: ['todo'],
});
expect(mockCall).toHaveBeenCalledWith({
method: 'openhuman.todos_edit',
params: {
thread_id: 't-1',
id: 'card-1',
content: 'New title',
approvalMode: null,
allowedTools: ['todo'],
},
});
});
it('updateStatus and remove call the matching RPC methods', async () => {
mockCall.mockResolvedValueOnce(snapshot([]));
await todosApi.updateStatus('t-1', 'card-1', 'done');
expect(mockCall).toHaveBeenLastCalledWith({
method: 'openhuman.todos_update_status',
params: { thread_id: 't-1', id: 'card-1', status: 'done' },
});
mockCall.mockResolvedValueOnce(snapshot([]));
await todosApi.remove('t-1', 'card-1');
expect(mockCall).toHaveBeenLastCalledWith({
method: 'openhuman.todos_remove',
params: { thread_id: 't-1', id: 'card-1' },
});
});
it('falls back to the request thread id when the snapshot omits one', async () => {
mockCall.mockResolvedValueOnce(snapshot([], null));
const board = await todosApi.list('t-xyz');
expect(board.threadId).toBe('t-xyz');
});
});
+169
View File
@@ -0,0 +1,169 @@
/**
* Frontend client for the per-board todo CRUD surface
* (`openhuman.todos_*`). The Rust handlers persist to the same
* `<workspace>/agent_task_boards/<hex(thread_id)>.json` store used by the
* agent task board, so user-driven edits made here and agent-driven edits
* made by the `todo` tool stay in lock-step.
*
* Boards are keyed by an arbitrary `thread_id` string the handlers never
* check that the thread exists so a reserved id (see
* {@link USER_TASKS_THREAD_ID}) backs a personal task board that is not
* attached to any conversation.
*/
import debug from 'debug';
import type {
TaskApprovalMode,
TaskBoard,
TaskBoardCard,
TaskBoardCardStatus,
} from '../../types/turnState';
import { callCoreRpc } from '../coreRpcClient';
const log = debug('todosApi');
/**
* Reserved board id for the user's personal task list tasks created
* without attaching to a conversation live here. Thread ids issued by the
* core are UUID/hex, so this human-readable sentinel never collides.
*/
export const USER_TASKS_THREAD_ID = 'user-tasks';
/** Wire shape returned by every `todos_*` handler (`TodosSnapshot`). */
interface TodosSnapshotWire {
threadId?: string | null;
cards?: TaskBoardCard[];
markdown?: string;
}
/** Fields accepted when creating a card. */
export interface AddTodoInput {
threadId: string;
content: string;
status?: TaskBoardCardStatus;
objective?: string | null;
notes?: string | null;
}
/** Fields accepted when editing a card. Omitted fields are left unchanged. */
export interface EditTodoInput {
threadId: string;
id: string;
content?: string;
status?: TaskBoardCardStatus;
objective?: string | null;
notes?: string | null;
blocker?: string | null;
assignedAgent?: string | null;
approvalMode?: TaskApprovalMode | null;
plan?: string[];
allowedTools?: string[];
acceptanceCriteria?: string[];
evidence?: string[];
}
/**
* Build a `TaskBoard` from a snapshot. The snapshot omits a board-level
* timestamp, so we derive `updatedAt` from the most-recently-touched card
* (falling back to "now") purely for ordering in the UI.
*/
function snapshotToBoard(snap: TodosSnapshotWire, fallbackThreadId: string): TaskBoard {
const cards = snap.cards ?? [];
const latest = cards.reduce<string>(
(acc, card) => (card.updatedAt && card.updatedAt > acc ? card.updatedAt : acc),
''
);
return {
threadId: snap.threadId ?? fallbackThreadId,
cards,
updatedAt: latest || new Date().toISOString(),
};
}
/**
* Strip `undefined` params so we only send fields the caller set
* `null` is preserved because the edit handler treats it as "clear".
*/
function pruneParams(params: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) out[key] = value;
}
return out;
}
export const todosApi = {
/** List the cards for a board. */
list: async (threadId: string): Promise<TaskBoard> => {
log('list threadId=%s', threadId);
const snap = await callCoreRpc<TodosSnapshotWire>({
method: 'openhuman.todos_list',
params: { thread_id: threadId },
});
return snapshotToBoard(snap, threadId);
},
/** Append a new card to a board. */
add: async (input: AddTodoInput): Promise<TaskBoard> => {
log('add threadId=%s', input.threadId);
const snap = await callCoreRpc<TodosSnapshotWire>({
method: 'openhuman.todos_add',
params: pruneParams({
thread_id: input.threadId,
content: input.content,
status: input.status,
objective: input.objective,
notes: input.notes,
}),
});
return snapshotToBoard(snap, input.threadId);
},
/** Edit an existing card by id. */
edit: async (input: EditTodoInput): Promise<TaskBoard> => {
log('edit threadId=%s id=%s', input.threadId, input.id);
const snap = await callCoreRpc<TodosSnapshotWire>({
method: 'openhuman.todos_edit',
params: pruneParams({
thread_id: input.threadId,
id: input.id,
content: input.content,
status: input.status,
objective: input.objective,
notes: input.notes,
blocker: input.blocker,
assignedAgent: input.assignedAgent,
approvalMode: input.approvalMode,
plan: input.plan,
allowedTools: input.allowedTools,
acceptanceCriteria: input.acceptanceCriteria,
evidence: input.evidence,
}),
});
return snapshotToBoard(snap, input.threadId);
},
/** Update only the status of a card. */
updateStatus: async (
threadId: string,
id: string,
status: TaskBoardCardStatus
): Promise<TaskBoard> => {
log('updateStatus threadId=%s id=%s status=%s', threadId, id, status);
const snap = await callCoreRpc<TodosSnapshotWire>({
method: 'openhuman.todos_update_status',
params: { thread_id: threadId, id, status },
});
return snapshotToBoard(snap, threadId);
},
/** Remove a card by id. */
remove: async (threadId: string, id: string): Promise<TaskBoard> => {
log('remove threadId=%s id=%s', threadId, id);
const snap = await callCoreRpc<TodosSnapshotWire>({
method: 'openhuman.todos_remove',
params: { thread_id: threadId, id },
});
return snapshotToBoard(snap, threadId);
},
};
+114
View File
@@ -1832,6 +1832,120 @@ async fn json_rpc_thread_labels_create_and_update() {
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_todos_crud_on_personal_board() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
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}");
// The Tasks-tab create flow targets a reserved, conversation-less board
// id. The `todos_*` handlers never require the thread to exist, so a
// user can manage a personal task list backed by this sentinel id.
let board = "user-tasks";
// 1. Add a user-created card.
let add = post_json_rpc(
&rpc_base,
9101,
"openhuman.todos_add",
json!({ "thread_id": board, "content": "Buy milk", "status": "todo" }),
)
.await;
let add_result = assert_no_jsonrpc_error(&add, "todos_add");
let cards = add_result
.get("cards")
.and_then(Value::as_array)
.expect("cards array in add response");
assert_eq!(cards.len(), 1, "exactly one card after add");
assert_eq!(
cards[0].get("title").and_then(Value::as_str),
Some("Buy milk"),
"added card title"
);
assert_eq!(
add_result.get("threadId").and_then(Value::as_str),
Some(board),
"snapshot echoes the board id"
);
let card_id = cards[0]
.get("id")
.and_then(Value::as_str)
.expect("card id")
.to_string();
// 2. List reflects the new card.
let list = post_json_rpc(
&rpc_base,
9102,
"openhuman.todos_list",
json!({ "thread_id": board }),
)
.await;
let list_result = assert_no_jsonrpc_error(&list, "todos_list");
assert_eq!(
list_result
.get("cards")
.and_then(Value::as_array)
.map(|c| c.len()),
Some(1),
"list returns the persisted card"
);
// 3. Move it to done.
let upd = post_json_rpc(
&rpc_base,
9103,
"openhuman.todos_update_status",
json!({ "thread_id": board, "id": card_id, "status": "done" }),
)
.await;
let upd_result = assert_no_jsonrpc_error(&upd, "todos_update_status");
let upd_cards = upd_result
.get("cards")
.and_then(Value::as_array)
.expect("cards in update response");
assert_eq!(
upd_cards[0].get("status").and_then(Value::as_str),
Some("done"),
"status updated to done"
);
// 4. Remove it — the board is empty again.
let rem = post_json_rpc(
&rpc_base,
9104,
"openhuman.todos_remove",
json!({ "thread_id": board, "id": card_id }),
)
.await;
let rem_result = assert_no_jsonrpc_error(&rem, "todos_remove");
assert!(
rem_result
.get("cards")
.and_then(Value::as_array)
.expect("cards in remove response")
.is_empty(),
"board empty after remove"
);
api_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_thread_title_create_and_update() {
let _env_lock = json_rpc_e2e_env_lock();