@treenity/mods 3.0.2 → 3.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/client.ts +2 -0
- package/agent/guardian.ts +492 -0
- package/agent/seed.ts +74 -0
- package/agent/server.ts +4 -0
- package/agent/service.ts +644 -0
- package/agent/types.ts +184 -0
- package/agent/view.tsx +431 -0
- package/mcp/mcp-server.ts +393 -0
- package/mcp/server.ts +2 -0
- package/mcp/service.ts +18 -0
- package/mcp/types.ts +6 -0
- package/package.json +5 -2
- package/dist/mindmap/radial-tree.d.ts +0 -14
- package/dist/mindmap/radial-tree.d.ts.map +0 -1
- package/dist/mindmap/radial-tree.js +0 -184
- package/dist/mindmap/radial-tree.js.map +0 -1
- package/dist/mindmap/use-tree-data.d.ts +0 -14
- package/dist/mindmap/use-tree-data.d.ts.map +0 -1
- package/dist/mindmap/use-tree-data.js +0 -95
- package/dist/mindmap/use-tree-data.js.map +0 -1
package/agent/types.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// AI Agent — autonomous worker in the tree.
|
|
2
|
+
// Role determines prompt + tool policy. Uses metatron's invokeClaude for LLM.
|
|
3
|
+
// Agent = node, tree = protocol.
|
|
4
|
+
|
|
5
|
+
import { registerType } from '@treenity/core/comp';
|
|
6
|
+
|
|
7
|
+
export type AgentStatus = 'idle' | 'working' | 'blocked' | 'error' | 'offline';
|
|
8
|
+
|
|
9
|
+
/** AI Agent — autonomous worker node at /agents/{name}
|
|
10
|
+
* LLM config (model, systemPrompt, sessionId) lives in named metatron.config component.
|
|
11
|
+
* Work creates metatron.task nodes under /agents/{name}/tasks/. */
|
|
12
|
+
export class AiAgent {
|
|
13
|
+
/** Open-ended role string. Guardian policies keyed by role. */
|
|
14
|
+
role = 'qa';
|
|
15
|
+
status: AgentStatus = 'offline';
|
|
16
|
+
/** Path to current board.task being worked on */
|
|
17
|
+
currentTask = '';
|
|
18
|
+
/** Path to current metatron.task (live log) */
|
|
19
|
+
taskRef = '';
|
|
20
|
+
lastRunAt = 0;
|
|
21
|
+
/** Total tokens used across all tasks */
|
|
22
|
+
totalTokens = 0;
|
|
23
|
+
|
|
24
|
+
/** @description Bring agent online */
|
|
25
|
+
online() {
|
|
26
|
+
this.status = 'idle';
|
|
27
|
+
this.currentTask = '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @description Take agent offline */
|
|
31
|
+
offline() {
|
|
32
|
+
if (this.status === 'working')
|
|
33
|
+
throw new Error('cannot go offline while working');
|
|
34
|
+
this.status = 'offline';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @description Assign a board task to this agent */
|
|
38
|
+
assign(data: { /** Path to board.task */ task: string }) {
|
|
39
|
+
if (this.status !== 'idle')
|
|
40
|
+
throw new Error(`cannot assign: agent is ${this.status}`);
|
|
41
|
+
if (!data.task?.trim()) throw new Error('task path required');
|
|
42
|
+
this.currentTask = data.task.trim();
|
|
43
|
+
this.status = 'working';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** @description Agent finished current task */
|
|
47
|
+
complete() {
|
|
48
|
+
if (this.status !== 'working')
|
|
49
|
+
throw new Error(`cannot complete: agent is ${this.status}`);
|
|
50
|
+
this.currentTask = '';
|
|
51
|
+
this.status = 'idle';
|
|
52
|
+
this.lastRunAt = Date.now();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @description Agent is blocked */
|
|
56
|
+
block(data?: { /** Reason */ reason?: string }) {
|
|
57
|
+
this.status = 'blocked';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @description Agent hit an error */
|
|
61
|
+
fail(data?: { /** Error message */ error?: string }) {
|
|
62
|
+
this.status = 'error';
|
|
63
|
+
this.currentTask = '';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Concurrency pool — lives on /agents node */
|
|
68
|
+
export class AiPool {
|
|
69
|
+
maxConcurrent = 2;
|
|
70
|
+
/** Paths of currently active agent nodes */
|
|
71
|
+
active: string[] = [];
|
|
72
|
+
/** Paths of agents waiting for a slot */
|
|
73
|
+
queue: string[] = [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** AI workflow on a board.task — routing + discussion cursor */
|
|
77
|
+
export class AiAssignment {
|
|
78
|
+
/** Who created the task */
|
|
79
|
+
origin = '';
|
|
80
|
+
/** Roles to wake for discussion (empty = no discussion pending) */
|
|
81
|
+
nextRoles: string[] = [];
|
|
82
|
+
/** agentPath → last read message index (don't re-send old messages) */
|
|
83
|
+
cursors: Record<string, number> = {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Discussion thread on a task — lightweight multi-agent chat */
|
|
87
|
+
export class AiThread {
|
|
88
|
+
messages: ThreadMessage[] = [];
|
|
89
|
+
|
|
90
|
+
/** @description Post a message to the discussion */
|
|
91
|
+
post(data: { role: string; from: string; text: string }) {
|
|
92
|
+
if (!data.text?.trim()) throw new Error('empty message');
|
|
93
|
+
this.messages.push({ role: data.role, from: data.from, text: data.text, ts: Date.now() });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type ThreadMessage = {
|
|
98
|
+
role: string;
|
|
99
|
+
from: string;
|
|
100
|
+
text: string;
|
|
101
|
+
ts: number;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** AI execution plan — lives as named component on board.task node.
|
|
105
|
+
* Agent writes plan first, human reviews + approves before execution. */
|
|
106
|
+
export class AiPlan {
|
|
107
|
+
/** @format textarea */
|
|
108
|
+
text = '';
|
|
109
|
+
approved = false;
|
|
110
|
+
/** @format textarea */
|
|
111
|
+
feedback = '';
|
|
112
|
+
createdAt = 0;
|
|
113
|
+
|
|
114
|
+
/** @description Approve the plan — agent will proceed to execute */
|
|
115
|
+
approvePlan(data?: { /** Optional feedback/adjustments for the agent */ feedback?: string }) {
|
|
116
|
+
if (this.approved) throw new Error('plan already approved');
|
|
117
|
+
if (!this.text) throw new Error('no plan to approve');
|
|
118
|
+
this.approved = true;
|
|
119
|
+
if (data?.feedback) this.feedback = data.feedback;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @description Reject the plan — agent will re-plan with feedback */
|
|
123
|
+
rejectPlan(data?: { /** What to change */ feedback?: string }) {
|
|
124
|
+
if (!this.text) throw new Error('no plan to reject');
|
|
125
|
+
this.approved = false;
|
|
126
|
+
if (data?.feedback) this.feedback = data.feedback;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
registerType('ai.plan', AiPlan);
|
|
131
|
+
|
|
132
|
+
/** Persistent tool policy — lives on agent (per-agent) or /agents/guardian (global) */
|
|
133
|
+
export class AiPolicy {
|
|
134
|
+
allow: string[] = [];
|
|
135
|
+
deny: string[] = [];
|
|
136
|
+
escalate: string[] = [];
|
|
137
|
+
/** @description Add a tool to allow list */
|
|
138
|
+
addAllow(data: { /** Tool name or glob pattern */ pattern: string }) {
|
|
139
|
+
if (!data.pattern?.trim()) throw new Error('pattern required');
|
|
140
|
+
const p = data.pattern.trim();
|
|
141
|
+
if (!this.allow.includes(p)) this.allow.push(p);
|
|
142
|
+
this.deny = this.deny.filter(d => d !== p);
|
|
143
|
+
this.escalate = this.escalate.filter(e => e !== p);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** @description Add a tool to deny list */
|
|
147
|
+
addDeny(data: { /** Tool name or glob pattern */ pattern: string }) {
|
|
148
|
+
if (!data.pattern?.trim()) throw new Error('pattern required');
|
|
149
|
+
const p = data.pattern.trim();
|
|
150
|
+
if (!this.deny.includes(p)) this.deny.push(p);
|
|
151
|
+
this.allow = this.allow.filter(a => a !== p);
|
|
152
|
+
this.escalate = this.escalate.filter(e => e !== p);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** @description Add a tool to escalate list (requires human approval) */
|
|
156
|
+
addEscalate(data: { /** Tool name or glob pattern */ pattern: string }) {
|
|
157
|
+
if (!data.pattern?.trim()) throw new Error('pattern required');
|
|
158
|
+
const p = data.pattern.trim();
|
|
159
|
+
if (!this.escalate.includes(p)) this.escalate.push(p);
|
|
160
|
+
this.allow = this.allow.filter(a => a !== p);
|
|
161
|
+
this.deny = this.deny.filter(d => d !== p);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** @description Remove a rule from all lists */
|
|
165
|
+
removeRule(data: { /** Pattern to remove */ pattern: string }) {
|
|
166
|
+
if (!data.pattern?.trim()) throw new Error('pattern required');
|
|
167
|
+
const p = data.pattern.trim();
|
|
168
|
+
this.allow = this.allow.filter(a => a !== p);
|
|
169
|
+
this.deny = this.deny.filter(d => d !== p);
|
|
170
|
+
this.escalate = this.escalate.filter(e => e !== p);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Approval inbox — field-level ref points to the approvals dir */
|
|
175
|
+
export class AiApprovals {
|
|
176
|
+
source: { $type: 'ref'; $ref: string } = { $type: 'ref', $ref: '' };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
registerType('ai.approvals', AiApprovals);
|
|
180
|
+
registerType('ai.policy', AiPolicy);
|
|
181
|
+
registerType('ai.agent', AiAgent);
|
|
182
|
+
registerType('ai.pool', AiPool);
|
|
183
|
+
registerType('ai.assignment', AiAssignment);
|
|
184
|
+
registerType('ai.thread', AiThread);
|
package/agent/view.tsx
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
// Agent Office — views for ai.pool, ai.agent, ai.approval, ai.thread
|
|
2
|
+
|
|
3
|
+
import { MetatronConfig } from '#metatron/types';
|
|
4
|
+
import { getComponent, register } from '@treenity/core';
|
|
5
|
+
import { Render, RenderContext, type View } from '@treenity/react/context';
|
|
6
|
+
import { execute, useChildren, useNavigate, usePath } from '@treenity/react/hooks';
|
|
7
|
+
import { minimd } from '@treenity/react/lib/minimd';
|
|
8
|
+
import { cn } from '@treenity/react/lib/utils';
|
|
9
|
+
import { useMemo, useState } from 'react';
|
|
10
|
+
import { AiApproval } from './guardian';
|
|
11
|
+
import { type AgentStatus, AiAgent, AiApprovals, AiPlan, AiPool, AiThread } from './types';
|
|
12
|
+
|
|
13
|
+
// ── Status styling ──
|
|
14
|
+
|
|
15
|
+
type StatusStyle = { bg: string; text: string; dot: string; label: string };
|
|
16
|
+
|
|
17
|
+
const AGENT_STATUS: Record<AgentStatus, StatusStyle> = {
|
|
18
|
+
idle: { bg: 'bg-emerald-500/10', text: 'text-emerald-400', dot: 'bg-emerald-400', label: 'idle' },
|
|
19
|
+
working: { bg: 'bg-sky-500/10', text: 'text-sky-400', dot: 'bg-sky-400 animate-pulse', label: 'working' },
|
|
20
|
+
blocked: { bg: 'bg-amber-500/10', text: 'text-amber-400', dot: 'bg-amber-400', label: 'blocked' },
|
|
21
|
+
error: { bg: 'bg-red-500/10', text: 'text-red-400', dot: 'bg-red-400', label: 'error' },
|
|
22
|
+
offline: { bg: 'bg-zinc-700/20', text: 'text-zinc-500', dot: 'bg-zinc-600', label: 'offline' },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const APPROVAL_STATUS: Record<string, { bg: string; text: string; border: string }> = {
|
|
26
|
+
pending: { bg: 'bg-amber-500/10', text: 'text-amber-400', border: 'border-amber-500/30' },
|
|
27
|
+
approved: { bg: 'bg-emerald-500/10', text: 'text-emerald-400', border: 'border-emerald-500/30' },
|
|
28
|
+
denied: { bg: 'bg-red-500/10', text: 'text-red-400', border: 'border-red-500/30' },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function timeAgo(ts: number): string {
|
|
32
|
+
if (!ts) return '';
|
|
33
|
+
const d = Date.now() - ts;
|
|
34
|
+
if (d < 60_000) return 'just now';
|
|
35
|
+
if (d < 3600_000) return `${Math.floor(d / 60_000)}m ago`;
|
|
36
|
+
if (d < 86400_000) return `${Math.floor(d / 3600_000)}h ago`;
|
|
37
|
+
return new Date(ts).toLocaleDateString('en', { month: 'short', day: 'numeric' });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function statusStyle(status: string): StatusStyle {
|
|
41
|
+
return AGENT_STATUS[status as AgentStatus] ?? AGENT_STATUS.offline;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function StatusDot({ status }: { status: string }) {
|
|
45
|
+
return <span className={cn('inline-block w-2 h-2 rounded-full', statusStyle(status).dot)} />;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── PoolView (ai.pool — dashboard) ──
|
|
49
|
+
|
|
50
|
+
const PoolView: View<AiPool> = ({ value, ctx }) => {
|
|
51
|
+
const path = ctx!.node.$path;
|
|
52
|
+
const agents = useChildren(path);
|
|
53
|
+
const approvals = useChildren(path + '/approvals');
|
|
54
|
+
|
|
55
|
+
const agentNodes = (agents ?? []).filter(n => n.$type === 'ai.agent');
|
|
56
|
+
const approvalNodes = (approvals ?? []).filter(n => n.$type === 'ai.approval');
|
|
57
|
+
const pendingApprovals = approvalNodes.filter(n => n.status === 'pending');
|
|
58
|
+
|
|
59
|
+
const activeCount = value.active?.length ?? 0;
|
|
60
|
+
const queueCount = value.queue?.length ?? 0;
|
|
61
|
+
const maxC = value.maxConcurrent ?? 2;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className="flex flex-col gap-6 p-5 max-w-3xl">
|
|
65
|
+
{/* Header */}
|
|
66
|
+
<div className="flex items-center justify-between">
|
|
67
|
+
<div>
|
|
68
|
+
<h2 className="text-lg font-semibold text-zinc-100 tracking-tight">Agent Office</h2>
|
|
69
|
+
<p className="text-xs text-zinc-500 mt-0.5 font-mono">
|
|
70
|
+
{activeCount}/{maxC} active · {queueCount} queued
|
|
71
|
+
</p>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{/* Pool capacity bar */}
|
|
75
|
+
<div className="flex gap-1">
|
|
76
|
+
{Array.from({ length: maxC }, (_, i) => (
|
|
77
|
+
<div
|
|
78
|
+
key={i}
|
|
79
|
+
className={cn(
|
|
80
|
+
'w-3 h-8 rounded-sm transition-colors duration-300',
|
|
81
|
+
i < activeCount ? 'bg-sky-500/60' : 'bg-zinc-800',
|
|
82
|
+
)}
|
|
83
|
+
/>
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
{/* Pending approvals */}
|
|
89
|
+
{pendingApprovals.length > 0 && (
|
|
90
|
+
<div className="flex flex-col gap-2">
|
|
91
|
+
<h3 className="text-[11px] font-medium text-amber-400 uppercase tracking-wider">
|
|
92
|
+
Pending Approvals ({pendingApprovals.length})
|
|
93
|
+
</h3>
|
|
94
|
+
{pendingApprovals.map((a) => (
|
|
95
|
+
<Render key={a.$path} value={a} />
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{/* Agents grid */}
|
|
101
|
+
<div className="flex flex-col gap-2">
|
|
102
|
+
<h3 className="text-[11px] font-medium text-zinc-500 uppercase tracking-wider">
|
|
103
|
+
Agents ({agentNodes.length})
|
|
104
|
+
</h3>
|
|
105
|
+
<RenderContext name="react:list">
|
|
106
|
+
{agentNodes.map((agent) => (
|
|
107
|
+
<Render key={agent.$path} value={agent} />
|
|
108
|
+
))}
|
|
109
|
+
</RenderContext>
|
|
110
|
+
{agentNodes.length === 0 && (
|
|
111
|
+
<p className="text-sm text-zinc-600 italic">No agents registered</p>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// ── AgentRow (compact card — react:list context) ──
|
|
119
|
+
|
|
120
|
+
const AgentRow: View<AiAgent> = ({ value, ctx }) => {
|
|
121
|
+
const nav = useNavigate();
|
|
122
|
+
const status = value.status || 'offline';
|
|
123
|
+
const s = statusStyle(status);
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<button
|
|
127
|
+
onClick={() => nav(ctx!.node.$path)}
|
|
128
|
+
className={cn(
|
|
129
|
+
'flex items-center gap-3 px-3.5 py-2.5 rounded-lg border transition-all duration-150',
|
|
130
|
+
'border-zinc-800/60 hover:border-zinc-700 hover:bg-zinc-800/30',
|
|
131
|
+
'text-left w-full group',
|
|
132
|
+
)}
|
|
133
|
+
>
|
|
134
|
+
<StatusDot status={status} />
|
|
135
|
+
|
|
136
|
+
<div className="flex-1 min-w-0">
|
|
137
|
+
<div className="flex items-center gap-2">
|
|
138
|
+
<span className="text-sm font-medium text-zinc-200 truncate">
|
|
139
|
+
{ctx!.node.$path.split('/').at(-1)}
|
|
140
|
+
</span>
|
|
141
|
+
<span className={cn('text-[10px] font-mono px-1.5 py-0.5 rounded', s.bg, s.text)}>
|
|
142
|
+
{value.role}
|
|
143
|
+
</span>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{status === 'working' && value.currentTask && (
|
|
147
|
+
<p className="text-[11px] text-zinc-500 truncate mt-0.5 font-mono">
|
|
148
|
+
→ {value.currentTask}
|
|
149
|
+
</p>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div className="flex items-center gap-3 text-[11px] text-zinc-600">
|
|
154
|
+
{value.totalTokens > 0 && (
|
|
155
|
+
<span className="font-mono">{(value.totalTokens / 100000).toFixed(2)}$</span>
|
|
156
|
+
)}
|
|
157
|
+
{value.lastRunAt > 0 && <span>{timeAgo(value.lastRunAt)}</span>}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<span className="text-zinc-700 group-hover:text-zinc-500 transition-colors">›</span>
|
|
161
|
+
</button>
|
|
162
|
+
);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// ── AgentView (ai.agent detail) ──
|
|
166
|
+
|
|
167
|
+
const AgentView: View<AiAgent> = ({ value, ctx }) => {
|
|
168
|
+
const path = ctx!.node.$path;
|
|
169
|
+
const status = value.status || 'offline';
|
|
170
|
+
const s = statusStyle(status);
|
|
171
|
+
const config = getComponent(ctx!.node, MetatronConfig);
|
|
172
|
+
const mtTask = usePath(value.taskRef || null);
|
|
173
|
+
|
|
174
|
+
return (
|
|
175
|
+
<div className="flex flex-col gap-5 p-5 max-w-2xl">
|
|
176
|
+
<div className="flex items-center gap-3">
|
|
177
|
+
<StatusDot status={status} />
|
|
178
|
+
<h2 className="text-lg font-semibold text-zinc-100">{path.split('/').at(-1)}</h2>
|
|
179
|
+
<span className={cn('text-xs font-mono px-2 py-0.5 rounded', s.bg, s.text)}>
|
|
180
|
+
{s.label}
|
|
181
|
+
</span>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
<div className="grid grid-cols-2 gap-3">
|
|
185
|
+
<InfoCell label="Role" value={value.role} />
|
|
186
|
+
<InfoCell label="Model" value={config?.model || '—'} mono />
|
|
187
|
+
<InfoCell label="Last Run" value={value.lastRunAt ? timeAgo(value.lastRunAt) : 'never'} />
|
|
188
|
+
<InfoCell label="Tokens" value={value.totalTokens > 0 ? `$${(value.totalTokens / 100000).toFixed(3)}` : '—'} mono />
|
|
189
|
+
{value.currentTask && <InfoCell label="Current Task" value={value.currentTask} mono span2 />}
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
{config?.systemPrompt && (
|
|
193
|
+
<div className="flex flex-col gap-1.5">
|
|
194
|
+
<span className="text-[11px] font-medium text-zinc-500 uppercase tracking-wider">System Prompt</span>
|
|
195
|
+
<pre className="text-xs text-zinc-400 bg-zinc-900/50 border border-zinc-800 rounded-lg p-3 whitespace-pre-wrap leading-relaxed max-h-48 overflow-y-auto">
|
|
196
|
+
{config.systemPrompt}
|
|
197
|
+
</pre>
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
|
|
201
|
+
{/* Live metatron.task log (D29) */}
|
|
202
|
+
{mtTask && (
|
|
203
|
+
<div className="flex flex-col gap-1.5">
|
|
204
|
+
<span className="text-[11px] font-medium text-sky-400 uppercase tracking-wider">Live Task</span>
|
|
205
|
+
<Render value={mtTask} />
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
<div className="flex gap-2 pt-2">
|
|
210
|
+
{status === 'offline' && (
|
|
211
|
+
<ActionBtn label="Bring Online" color="emerald" onClick={() => execute(path, 'online')} />
|
|
212
|
+
)}
|
|
213
|
+
{status === 'idle' && (
|
|
214
|
+
<ActionBtn label="Take Offline" color="zinc" onClick={() => execute(path, 'offline')} />
|
|
215
|
+
)}
|
|
216
|
+
{status === 'error' && (
|
|
217
|
+
<ActionBtn label="Bring Online" color="emerald" onClick={() => execute(path, 'online')} />
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
function InfoCell({ label, value, mono, span2 }: { label: string; value: string; mono?: boolean; span2?: boolean }) {
|
|
225
|
+
return (
|
|
226
|
+
<div className={cn('bg-zinc-900/40 border border-zinc-800/50 rounded-lg px-3 py-2', span2 && 'col-span-2')}>
|
|
227
|
+
<span className="text-[10px] text-zinc-600 uppercase tracking-wider block">{label}</span>
|
|
228
|
+
<span className={cn('text-sm text-zinc-300 mt-0.5 block truncate', mono && 'font-mono text-xs')}>{value}</span>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function ActionBtn({ label, color, onClick }: { label: string; color: string; onClick: () => void }) {
|
|
234
|
+
const colors: Record<string, string> = {
|
|
235
|
+
emerald: 'bg-emerald-600/20 text-emerald-400 hover:bg-emerald-600/30 border-emerald-500/20',
|
|
236
|
+
zinc: 'bg-zinc-700/30 text-zinc-400 hover:bg-zinc-700/50 border-zinc-600/20',
|
|
237
|
+
red: 'bg-red-600/20 text-red-400 hover:bg-red-600/30 border-red-500/20',
|
|
238
|
+
};
|
|
239
|
+
return (
|
|
240
|
+
<button
|
|
241
|
+
onClick={onClick}
|
|
242
|
+
className={cn('px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors', colors[color] ?? colors.zinc)}
|
|
243
|
+
>
|
|
244
|
+
{label}
|
|
245
|
+
</button>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── ApprovalsView (ai.approvals — container) ──
|
|
250
|
+
|
|
251
|
+
const ApprovalsView: View<AiApprovals> = ({ value, ctx }) => {
|
|
252
|
+
const children = useChildren(ctx!.node.$path, { watch: true, watchNew: true });
|
|
253
|
+
const pending = children.filter(n => n.$type === 'ai.approval' && n.status === 'pending');
|
|
254
|
+
const resolved = children.filter(n => n.$type === 'ai.approval' && n.status !== 'pending');
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<div className="flex flex-col gap-5 p-5 max-w-3xl">
|
|
258
|
+
<h2 className="text-lg font-semibold text-zinc-100 tracking-tight">Approvals</h2>
|
|
259
|
+
|
|
260
|
+
{pending.length > 0 && (
|
|
261
|
+
<div className="flex flex-col gap-2">
|
|
262
|
+
<h3 className="text-[11px] font-medium text-amber-400 uppercase tracking-wider">
|
|
263
|
+
Pending ({pending.length})
|
|
264
|
+
</h3>
|
|
265
|
+
{pending.map(a => <Render key={a.$path} value={a} />)}
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
|
|
269
|
+
{pending.length === 0 && (
|
|
270
|
+
<p className="text-sm text-zinc-600 italic">No pending approvals</p>
|
|
271
|
+
)}
|
|
272
|
+
|
|
273
|
+
{resolved.length > 0 && (
|
|
274
|
+
<div className="flex flex-col gap-2">
|
|
275
|
+
<h3 className="text-[11px] font-medium text-zinc-600 uppercase tracking-wider">
|
|
276
|
+
History ({resolved.length})
|
|
277
|
+
</h3>
|
|
278
|
+
{resolved.slice(0, 20).map(a => <Render key={a.$path} value={a} />)}
|
|
279
|
+
</div>
|
|
280
|
+
)}
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── ApprovalView (ai.approval) ──
|
|
286
|
+
|
|
287
|
+
const ApprovalView: View<AiApproval> = ({ value, ctx }) => {
|
|
288
|
+
const path = ctx!.node.$path;
|
|
289
|
+
const status = value.status || 'pending';
|
|
290
|
+
const s = APPROVAL_STATUS[status] ?? APPROVAL_STATUS.pending;
|
|
291
|
+
const isPending = status === 'pending';
|
|
292
|
+
|
|
293
|
+
return (
|
|
294
|
+
<div className={cn('border rounded-lg px-4 py-3 flex flex-col gap-2', s.border, s.bg)}>
|
|
295
|
+
<div className="flex items-center justify-between">
|
|
296
|
+
<div className="flex items-center gap-2">
|
|
297
|
+
<span className={cn('text-xs font-medium', s.text)}>{status}</span>
|
|
298
|
+
<span className="text-[11px] text-zinc-500 font-mono">{value.agentRole}</span>
|
|
299
|
+
<span className="text-[11px] text-zinc-600">→</span>
|
|
300
|
+
<span className="text-[11px] text-zinc-400 font-mono">{value.tool}</span>
|
|
301
|
+
</div>
|
|
302
|
+
<span className="text-[10px] text-zinc-600">{timeAgo(value.createdAt)}</span>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
{value.input && (
|
|
306
|
+
<pre className="text-[11px] text-zinc-500 bg-black/20 rounded px-2 py-1 max-h-20 overflow-y-auto whitespace-pre-wrap">
|
|
307
|
+
{value.input}
|
|
308
|
+
</pre>
|
|
309
|
+
)}
|
|
310
|
+
{value.reason && <p className="text-[11px] text-zinc-500 italic">{value.reason}</p>}
|
|
311
|
+
|
|
312
|
+
{isPending && (
|
|
313
|
+
<div className="flex items-center gap-2 pt-1 flex-wrap">
|
|
314
|
+
<ActionBtn label="Approve" color="emerald" onClick={() => execute(path, 'approve')} />
|
|
315
|
+
<ActionBtn label="Deny" color="red" onClick={() => execute(path, 'deny')} />
|
|
316
|
+
<span className="text-[10px] text-zinc-700 mx-1">|</span>
|
|
317
|
+
<ActionBtn label="Approve + remember (agent)" color="emerald" onClick={() => execute(path, 'approve', { remember: 'agent' })} />
|
|
318
|
+
<ActionBtn label="Approve + remember (global)" color="emerald" onClick={() => execute(path, 'approve', { remember: 'global' })} />
|
|
319
|
+
</div>
|
|
320
|
+
)}
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// ── ThreadView (ai.thread — message list) ──
|
|
326
|
+
|
|
327
|
+
const ThreadView: View<AiThread> = ({ value }) => {
|
|
328
|
+
const messages = value?.messages ?? [];
|
|
329
|
+
|
|
330
|
+
if (!messages.length) {
|
|
331
|
+
return <p className="text-sm text-zinc-600 italic p-4">No messages yet</p>;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return (
|
|
335
|
+
<div className="flex flex-col gap-1 p-4">
|
|
336
|
+
{messages.map((msg, i) => (
|
|
337
|
+
<div key={i} className="flex gap-2 py-1.5 group">
|
|
338
|
+
<span className="text-[11px] font-mono text-sky-500/70 w-16 shrink-0 text-right pt-0.5">
|
|
339
|
+
{msg.role}
|
|
340
|
+
</span>
|
|
341
|
+
<div className="flex-1 min-w-0">
|
|
342
|
+
<p className="text-sm text-zinc-300 leading-relaxed whitespace-pre-wrap">{msg.text}</p>
|
|
343
|
+
<span className="text-[10px] text-zinc-700 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
344
|
+
{msg.from} · {timeAgo(msg.ts)}
|
|
345
|
+
</span>
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
))}
|
|
349
|
+
</div>
|
|
350
|
+
);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// ── PlanView (ai.plan — approve/reject) ──
|
|
354
|
+
|
|
355
|
+
const PlanView: View<AiPlan> = ({ value, ctx }) => {
|
|
356
|
+
const [feedback, setFeedback] = useState('');
|
|
357
|
+
const [busy, setBusy] = useState(false);
|
|
358
|
+
const html = useMemo(() => value.text ? minimd(value.text) : '', [value.text]);
|
|
359
|
+
|
|
360
|
+
if (!value.text && !value.feedback) return null;
|
|
361
|
+
|
|
362
|
+
const doAction = async (action: 'approvePlan' | 'rejectPlan') => {
|
|
363
|
+
if (!ctx) return;
|
|
364
|
+
setBusy(true);
|
|
365
|
+
try {
|
|
366
|
+
await ctx.execute(action, feedback.trim() ? { feedback: feedback.trim() } : undefined);
|
|
367
|
+
setFeedback('');
|
|
368
|
+
} finally {
|
|
369
|
+
setBusy(false);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
if (value.approved) {
|
|
374
|
+
return (
|
|
375
|
+
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-3">
|
|
376
|
+
<div className="mb-1 text-[11px] font-medium text-emerald-400 uppercase tracking-wider">Plan approved</div>
|
|
377
|
+
<div className="minimd max-h-40 overflow-y-auto text-sm text-zinc-300" dangerouslySetInnerHTML={{ __html: html }} />
|
|
378
|
+
{value.feedback && (
|
|
379
|
+
<div className="mt-2 border-t border-emerald-500/20 pt-2 text-xs text-zinc-500">
|
|
380
|
+
Feedback: {value.feedback}
|
|
381
|
+
</div>
|
|
382
|
+
)}
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return (
|
|
388
|
+
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3">
|
|
389
|
+
<div className="mb-2 flex items-center justify-between">
|
|
390
|
+
<span className="text-[11px] font-medium text-amber-400 uppercase tracking-wider">Plan awaiting approval</span>
|
|
391
|
+
<span className="text-[10px] text-zinc-600">
|
|
392
|
+
{value.createdAt ? new Date(value.createdAt).toLocaleString() : ''}
|
|
393
|
+
</span>
|
|
394
|
+
</div>
|
|
395
|
+
|
|
396
|
+
<div
|
|
397
|
+
className="minimd mb-3 max-h-60 overflow-y-auto rounded border border-zinc-800 bg-zinc-900/50 p-2 text-sm text-zinc-300"
|
|
398
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
399
|
+
/>
|
|
400
|
+
|
|
401
|
+
{value.feedback && (
|
|
402
|
+
<div className="mb-2 rounded border border-red-500/20 bg-red-500/5 px-3 py-2 text-xs text-red-300">
|
|
403
|
+
<span className="font-medium text-red-400">Rejection feedback:</span> {value.feedback}
|
|
404
|
+
</div>
|
|
405
|
+
)}
|
|
406
|
+
|
|
407
|
+
<textarea
|
|
408
|
+
value={feedback}
|
|
409
|
+
onChange={e => setFeedback(e.target.value)}
|
|
410
|
+
placeholder="Feedback / comments (optional)..."
|
|
411
|
+
className="mb-2 w-full min-h-16 max-h-32 resize-none rounded border border-zinc-800 bg-zinc-900/50 p-2 text-sm text-zinc-300 placeholder:text-zinc-600 focus:outline-none focus:border-zinc-700"
|
|
412
|
+
/>
|
|
413
|
+
|
|
414
|
+
<div className="flex items-center gap-2">
|
|
415
|
+
<ActionBtn label="Approve Plan" color="emerald" onClick={() => doAction('approvePlan')} />
|
|
416
|
+
<ActionBtn label="Reject" color="red" onClick={() => doAction('rejectPlan')} />
|
|
417
|
+
{busy && <span className="text-[10px] text-zinc-600">sending...</span>}
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// ── Register views ──
|
|
424
|
+
|
|
425
|
+
register(AiPool, 'react', PoolView);
|
|
426
|
+
register(AiAgent, 'react', AgentView);
|
|
427
|
+
register(AiAgent, 'react:list', AgentRow);
|
|
428
|
+
register(AiApproval, 'react', ApprovalView);
|
|
429
|
+
register(AiApprovals, 'react', ApprovalsView);
|
|
430
|
+
register(AiThread, 'react', ThreadView);
|
|
431
|
+
register(AiPlan, 'react', PlanView);
|