Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions app/src/pages/Conversations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1120,6 +1120,19 @@ const Conversations = ({
}
};

const handleDecidePlan = async (card: TaskBoardCard, approve: boolean): Promise<void> => {
if (!selectedThreadId) return;
try {
const saved = await threadApi.decidePlan(selectedThreadId, card.id, approve);
if (saved) {
dispatch(setTaskBoardForThread({ threadId: selectedThreadId, board: saved }));
}
} catch (error) {
debug('decidePlan failed: %o', error);
setSendAdvisory(t('conversations.taskKanban.updateFailed'));
}
};

const handleUpdateTaskCard = async (
card: TaskBoardCard,
nextCard: TaskBoardCard
Expand Down Expand Up @@ -1584,6 +1597,9 @@ const Conversations = ({
onUpdateCard={(card, nextCard) => {
void handleUpdateTaskCard(card, nextCard);
}}
onDecidePlan={(card, approve) => {
void handleDecidePlan(card, approve);
}}
/>
)}
{visibleMessages.map(msg => (
Expand Down
49 changes: 46 additions & 3 deletions app/src/pages/conversations/components/TaskKanbanBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,18 +24,42 @@ const COLUMN_DEFS: ColumnDef[] = [

const STATUS_INDEX = new Map(COLUMN_DEFS.map((column, index) => [column.status, index]));

/** Whether a status owns a kanban column (vs the approval-flow 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`. */
function columnFor(status: TaskBoardCardStatus): TaskBoardCardStatus {
switch (status) {
case 'awaiting_approval':
case 'ready':
return 'todo';
case 'rejected':
return 'blocked';
default:
return status;
}
}

interface TaskKanbanBoardProps {
board: TaskBoard;
disabled?: boolean;
onMove?: (card: TaskBoardCard, status: TaskBoardCardStatus) => void;
onUpdateCard?: (card: TaskBoardCard, nextCard: TaskBoardCard) => void;
/** Approve/reject a card awaiting plan approval. */
onDecidePlan?: (card: TaskBoardCard, approve: boolean) => void;
}

export function TaskKanbanBoard({
board,
disabled = false,
onMove,
onUpdateCard,
onDecidePlan,
}: TaskKanbanBoardProps) {
const { t } = useT();
const [selectedCardId, setSelectedCardId] = useState<string | null>(null);
Expand All @@ -55,7 +79,7 @@ export function TaskKanbanBoard({
);

for (const card of [...board.cards].sort((a, b) => a.order - b.order)) {
cardsByStatus[card.status]?.push(card);
cardsByStatus[columnFor(card.status)]?.push(card);
}

const moveCard = (card: TaskBoardCard, direction: -1 | 1) => {
Expand Down Expand Up @@ -97,7 +121,26 @@ export function TaskKanbanBoard({
<p className="min-w-0 flex-1 break-words text-xs font-medium leading-snug text-stone-800 dark:text-neutral-100">
{card.title}
</p>
{onMove && (
{card.status === 'awaiting_approval' && onDecidePlan ? (
<div className="flex flex-shrink-0 items-center gap-1">
<button
type="button"
title={t('chat.approval.approve')}
disabled={disabled}
onClick={() => onDecidePlan(card, true)}
className="rounded-md bg-ocean-600 px-1.5 py-0.5 text-[10px] font-medium text-white transition-colors hover:bg-ocean-700 disabled:opacity-40">
{t('chat.approval.approve')}
</button>
<button
type="button"
title={t('chat.approval.deny')}
disabled={disabled}
onClick={() => onDecidePlan(card, false)}
className="rounded-md border border-stone-200 px-1.5 py-0.5 text-[10px] font-medium text-stone-600 transition-colors hover:bg-stone-100 disabled:opacity-40 dark:border-neutral-700 dark:text-neutral-300 dark:hover:bg-neutral-800">
{t('chat.approval.deny')}
</button>
</div>
) : onMove && isColumnStatus(card.status) ? (
<div className="flex flex-shrink-0 items-center gap-0.5">
<button
type="button"
Expand All @@ -118,7 +161,7 @@ export function TaskKanbanBoard({
<LuArrowRight className="h-3 w-3" />
</button>
</div>
)}
) : null}
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{card.assignedAgent && (
Expand Down
26 changes: 26 additions & 0 deletions app/src/services/api/threadApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,32 @@ export const threadApi = {
return data?.taskBoard ?? null;
},

/**
* Approve or reject a task-board card that is awaiting plan approval
* (`openhuman.todos_decide_plan`). Approve → the card becomes runnable
* (`ready`); reject → `rejected`. Returns the updated board (rebuilt from
* the returned todos snapshot) or null.
*/
decidePlan: async (
threadId: string,
cardId: string,
approve: boolean
): Promise<TaskBoard | null> => {
const response = await callCoreRpc<{
data?: { threadId?: string | null; cards?: TaskBoardCard[] };
}>({
method: 'openhuman.todos_decide_plan',
params: { thread_id: threadId, id: cardId, approve },
});
const data = unwrapEnvelope(response);
if (!data?.cards) return null;
return {
threadId: data.threadId ?? threadId,
cards: data.cards,
updatedAt: new Date().toISOString(),
};
},

updateLabels: async (threadId: string, labels: string[]): Promise<Thread> => {
const response = await callCoreRpc<Envelope<Thread>>({
method: 'openhuman.threads_update_labels',
Expand Down
13 changes: 12 additions & 1 deletion app/src/types/turnState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@ export type PersistedTurnPhase = 'thinking' | 'tool_use' | 'subagent';

export type PersistedToolStatus = 'running' | 'success' | 'error';

export type TaskBoardCardStatus = 'todo' | 'in_progress' | 'blocked' | 'done';
export type TaskBoardCardStatus =
| 'todo'
| 'awaiting_approval'
| 'ready'
| 'in_progress'
| 'blocked'
| 'done'
| 'rejected';
export type TaskApprovalMode = 'required' | 'not_required';

export interface TaskBoardCard {
Expand All @@ -27,6 +34,10 @@ export interface TaskBoardCard {
evidence?: string[];
notes?: string | null;
blocker?: string | null;
/** Provider/source identifiers for a card ingested from a task source
* (`{provider, source_id, external_id, url, repo?, urgency}`); absent on
* agent/UI-authored cards. */
sourceMetadata?: Record<string, unknown> | null;
order: number;
updatedAt: string;
}
Expand Down
7 changes: 7 additions & 0 deletions src/core/event_bus/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,10 @@ pub enum DomainEvent {
provider: String,
error: String,
},
/// A task-board card needs human plan approval before the dispatcher will
/// execute it (emitted when `autonomy.require_task_plan_approval` is on and
/// the dispatcher parks a `todo` card at `awaiting_approval`).
TaskPlanAwaitingApproval { card_id: String, thread_id: String },
}

impl DomainEvent {
Expand Down Expand Up @@ -747,6 +751,8 @@ impl DomainEvent {
| Self::TaskSourceTaskIngested { .. }
| Self::TaskSourceFetchFailed { .. } => "task_sources",

Self::TaskPlanAwaitingApproval { .. } => "agent",

Self::ApprovalRequested { .. } | Self::ApprovalDecided { .. } => "approval",

Self::McpServerInstalled { .. }
Expand Down Expand Up @@ -837,6 +843,7 @@ impl DomainEvent {
Self::TaskSourceFetched { .. } => "TaskSourceFetched",
Self::TaskSourceTaskIngested { .. } => "TaskSourceTaskIngested",
Self::TaskSourceFetchFailed { .. } => "TaskSourceFetchFailed",
Self::TaskPlanAwaitingApproval { .. } => "TaskPlanAwaitingApproval",
}
}

Expand Down
3 changes: 3 additions & 0 deletions src/core/jsonrpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1846,6 +1846,9 @@ fn register_domain_subscribers(
// Task-sources proactive ingestion: connection-created hook + poll.
crate::openhuman::task_sources::bus::register_task_sources_subscriber();
crate::openhuman::task_sources::start_periodic_poll();
// Board poller: dispatch the highest-urgency `todo` card on the
// task-sources board (catch-all for cards without a proactive trigger).
crate::openhuman::agent::task_dispatcher::start_board_poller();
// Seed memory_sources with active Composio connections so the
// user sees their connected integrations as memory sources by
// default. Best-effort: failure is logged but does not block startup.
Expand Down
1 change: 1 addition & 0 deletions src/openhuman/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub mod prompts;
mod schemas;
pub mod stop_hooks;
pub mod task_board;
pub mod task_dispatcher;
pub mod tool_policy;
pub mod tree_loader;
pub mod triage;
Expand Down
23 changes: 23 additions & 0 deletions src/openhuman/agent/task_board.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,31 @@ const TASK_BOARD_EXTENSION: &str = "json";
#[serde(rename_all = "snake_case")]
pub enum TaskCardStatus {
Todo,
/// Plan approval required and pending — the dispatcher parked the card here
/// and emitted `TaskPlanAwaitingApproval`; it will not run until a human
/// approves (→ `Ready`) or rejects (→ `Rejected`).
AwaitingApproval,
/// Approved for execution — the dispatcher runs `Ready` cards without a
/// further approval check (distinguishes "approved" from the initial
/// `Todo`, which the approval gate would otherwise re-park).
Ready,
InProgress,
Blocked,
Done,
/// Plan approval was denied; the card is not executed.
Rejected,
}

impl TaskCardStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Todo => "todo",
Self::AwaitingApproval => "awaiting_approval",
Self::Ready => "ready",
Self::InProgress => "in_progress",
Self::Blocked => "blocked",
Self::Done => "done",
Self::Rejected => "rejected",
}
}
}
Expand Down Expand Up @@ -74,6 +87,12 @@ pub struct TaskBoardCard {
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blocker: Option<String>,
/// Provider/source identifiers for a card ingested from a task source
/// (`{provider, source_id, external_id, url, repo?, urgency}`). Set by
/// the `task_sources` route; consumed downstream for prioritisation and
/// external write-back. `None` for agent/UI-authored cards.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_metadata: Option<serde_json::Value>,
#[serde(default)]
pub order: u32,
#[serde(default)]
Expand Down Expand Up @@ -420,6 +439,7 @@ mod tests {
evidence: vec![" cargo test ".into()],
notes: Some(" note ".into()),
blocker: None,
source_metadata: None,
order: 99,
updated_at: String::new(),
},
Expand All @@ -436,6 +456,7 @@ mod tests {
evidence: Vec::new(),
notes: Some("waiting on user".into()),
blocker: None,
source_metadata: None,
order: 99,
updated_at: String::new(),
},
Expand Down Expand Up @@ -506,6 +527,7 @@ mod tests {
evidence: Vec::new(),
notes: None,
blocker: None,
source_metadata: None,
order: 99,
updated_at: String::new(),
},
Expand All @@ -522,6 +544,7 @@ mod tests {
evidence: Vec::new(),
notes: None,
blocker: None,
source_metadata: None,
order: 99,
updated_at: String::new(),
},
Expand Down
Loading
Loading